import 'package:flutter/material.dart'; import 'package:skywood/global/client/event_client.dart'; import 'package:skywood/global/client/play_client.dart'; import 'package:skywood/main/lib_file.dart'; import 'package:skywood/pages/home/players/page/drama_esp_page.dart'; import 'package:skywood/utils/models/drama/skywood_detail.dart'; import 'package:skywood/utils/models/drama/skywood_drama.dart'; import 'package:skywood/utils/models/drama/skywood_episode.dart'; import 'package:skywood/utils/tools/widgets/sky_common_widget.dart'; import 'package:video_player/video_player.dart'; /// 视频播放器 class SkyPlayerView extends StatefulWidget { // final String videoUrl; final bool autoPlay; final SkywoodDetail? detailBean; final SkywoodDrama dramaBean; final int? currentEpisode; final ValueChanged? onSelectedEpisode; const SkyPlayerView({ super.key, // required this.videoUrl, this.autoPlay = true, this.detailBean, required this.dramaBean, this.currentEpisode, this.onSelectedEpisode, }); @override State createState() => _VideoPlayerWidgetState(); } class _VideoPlayerWidgetState extends State { late VideoPlayerController _controller; bool _controllerCreated = false; bool _isPlaying = false; bool _isInitialized = false; bool _isSeeking = false; String? _errorMessage; Duration _currentPosition = Duration.zero; Duration _totalDuration = Duration.zero; bool _isLiked = false; SkywoodEpisode? _currentEpisode; // 是否已经上报his bool _isReportedHis = false; @override void initState() { super.initState(); _initializePlayer(); } @override void didUpdateWidget(SkyPlayerView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.detailBean != widget.detailBean || oldWidget.currentEpisode != widget.currentEpisode) { _disposeController(); _initializePlayer(); return; } if (oldWidget.autoPlay != widget.autoPlay && _controllerCreated && _isInitialized) { if (widget.autoPlay) { _controller.play(); } else { _controller.pause(); } } } Future _initializePlayer() async { setState(() { _isInitialized = false; _isPlaying = false; _errorMessage = null; _currentPosition = Duration.zero; _totalDuration = Duration.zero; _isLiked = widget.dramaBean.isCollect ?? false; _isReportedHis = false; }); _currentEpisode = _resolveCurrentEpisode(); String videoUrl = _currentEpisode?.videoUrl ?? ''; final controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); _controller = controller; _controllerCreated = true; controller.addListener(_handlePlayerValueChanged); try { await controller.initialize(); if (!mounted || controller != _controller || !_controllerCreated) return; if (widget.autoPlay) { await controller.play(); } _syncPlayerValue(); } catch (error) { if (!mounted || controller != _controller) return; setState(() { _errorMessage = error.toString(); _isInitialized = false; _isPlaying = false; }); } } void _handlePlayerValueChanged() { if (!mounted || !_controllerCreated) return; _syncPlayerValue(); final videoController = _controller; final position = videoController.value.position; if (_isPlaying && !_isReportedHis && _currentEpisode != null && position.inSeconds > 1) { _isReportedHis = true; EventClient.eventCreateHis( shortPlayId: _currentEpisode!.shortPlayId.toString(), videoId: _currentEpisode!.shortPlayVideoId.toString(), ); } } void _syncPlayerValue() { final value = _controller.value; final nextPosition = _isSeeking ? _currentPosition : value.position; final nextDuration = value.duration; final nextPlaying = value.isPlaying; setState(() { _isInitialized = value.isInitialized; _isPlaying = nextPlaying; _currentPosition = nextPosition; _totalDuration = nextDuration; }); } void _disposeController() { if (!_controllerCreated) return; _controller.removeListener(_handlePlayerValueChanged); _controller.dispose(); _controllerCreated = false; } @override void dispose() { _disposeController(); super.dispose(); } @override Widget build(BuildContext context) { if (_errorMessage != null) { return _buildErrorView(); } return _isInitialized && _controllerCreated ? _buildVideoContent() : _buildLoadingView(); } Widget _buildVideoContent() { return Stack( fit: StackFit.expand, children: [ FittedBox( fit: BoxFit.cover, child: SizedBox( width: _controller.value.size.width, height: _controller.value.size.height, child: VideoPlayer(_controller), ), ), Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: _togglePlay, child: Container(), ), ), if (widget.detailBean != null) Positioned(bottom: 0, left: 0, right: 0, child: _buildBottomView()), if (!_isPlaying) Center( child: GestureDetector( onTap: _togglePlay, child: Container( width: 60, height: 60, decoration: BoxDecoration( color: Colors.white.withAlpha(204), shape: BoxShape.circle, ), child: const Icon( Icons.play_arrow_rounded, color: Colors.black, size: 40, ), ), ), ), ], ); } Widget _buildLoadingView() { return Container( color: Colors.black, child: Center(child: HelpImg.asset(ConstImg.loading, width: 110)), ); } Widget _buildErrorView() { return Container( color: Colors.black, alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 24), child: SkyText( text: 'Video failed to load', color: Colors.white.withAlpha(180), fontSize: 14, ), ); } void _togglePlay() { if (!_isInitialized || !_controllerCreated) return; if (_controller.value.isPlaying) { _controller.pause(); } else { _controller.play(); } } SkywoodEpisode? _resolveCurrentEpisode() { final detail = widget.detailBean; if (detail == null || detail.episodeList.isEmpty) return null; final int? episode = widget.currentEpisode ?? detail.videoInfo?.episode ?? detail.videoInfo?.currentEpisode ?? detail.shortPlayInfo?.episode ?? detail.shortPlayInfo?.currentEpisode; if (episode != null && episode > 0) { for (final item in detail.episodeList) { if (item.episode == episode) return item; } } return detail.episodeList.first; } void _collectionDrama() { if (_currentEpisode == null) return; if (_isLiked) { PlayClient.cancelCollect(shortPlayId: _currentEpisode!.shortPlayId).then(( value, ) { setState(() { _isLiked = value; }); }); } else { PlayClient.collect( shortPlayId: _currentEpisode!.shortPlayId, videoId: _currentEpisode!.shortPlayVideoId, ).then((value) { setState(() { _isLiked = value; }); }); } } Widget _buildBottomView() { SkywoodDetail detail = widget.detailBean!; final int currentEpisode = _getCurrentEpisode(detail); final int totalEpisode = _episodeTotal(detail); return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 15), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ InkWell( onTap: () => _collectionDrama(), child: HelpImg.asset( _isLiked ? ConstImg.liked : ConstImg.unlike, width: 40, height: 40, ), ), const SizedBox(height: 24), Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), color: Colors.white.withAlpha(30), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ HelpImg.asset(ConstImg.playLt, width: 17, height: 17), const SizedBox(width: 10), SkyText( text: 'EP.$currentEpisode', fontSize: 13, color: Colors.white, ), SkyText( text: ' / EP.$totalEpisode', fontSize: 13, color: Colors.white.withAlpha(126), ), ], ), Icon(Icons.arrow_forward_ios, color: Colors.white, size: 13), ], ), ).addInkWell( onTap: () { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) { return SizedBox( height: 520, child: DramaEspPage( detail: detail, currentEpisode: currentEpisode, onSelectedEpisode: (episode) { Navigator.pop(context); widget.onSelectedEpisode?.call(episode); }, ), ); }, ); }, ), const SizedBox(height: 8), _buildProgressBar(), const SizedBox(height: 7), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ SkyText( text: _formatDuration(_currentPosition), fontSize: 13, color: Colors.white, ), SkyText( text: ' / ${_formatDuration(_totalDuration)}', fontSize: 13, color: Colors.white.withAlpha(126), ), ], ), SkyText(text: 'x1.0', fontSize: 13, color: Colors.white), ], ), const SizedBox(height: 20), ], ), ); } Widget _buildProgressBar() { final int totalMilliseconds = _totalDuration.inMilliseconds; final double progress = totalMilliseconds <= 0 ? 0 : (_currentPosition.inMilliseconds / totalMilliseconds).clamp(0.0, 1.0); return SizedBox( width: double.infinity, height: 28, child: SliderTheme( data: SliderThemeData( trackHeight: 3, // ✅ 进度条高度 3 thumbShape: _ImageThumbShape(imagePath: ConstImg.playTag), overlayShape: const RoundSliderOverlayShape(overlayRadius: 10), activeTrackColor: Colors.white, // ✅ 已播放 = 白色 inactiveTrackColor: Colors.white.withAlpha(126), // ✅ 未播放 = 灰色 thumbColor: Colors.white, overlayColor: Colors.white.withAlpha(76), ), child: Slider( padding: EdgeInsets.zero, value: progress, min: 0, max: 1, onChangeStart: (value) { if (totalMilliseconds <= 0) return; setState(() { _isSeeking = true; _currentPosition = _durationFromProgress( value, totalMilliseconds, ); }); }, onChanged: (value) { if (totalMilliseconds <= 0) return; setState(() { _currentPosition = _durationFromProgress( value, totalMilliseconds, ); }); }, onChangeEnd: (value) { if (totalMilliseconds <= 0 || !_controllerCreated) return; final position = _durationFromProgress(value, totalMilliseconds); setState(() { _currentPosition = position; }); _controller.seekTo(position).whenComplete(() { if (!mounted) return; _isSeeking = false; _syncPlayerValue(); }); }, ), ), ); } Duration _durationFromProgress(double value, int totalMilliseconds) { return Duration(milliseconds: (totalMilliseconds * value).round()); } String _formatDuration(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } int _getCurrentEpisode(SkywoodDetail detail) { if (_currentEpisode?.episode != null && _currentEpisode!.episode > 0) { return _currentEpisode!.episode; } final int? episode = detail.videoInfo?.episode ?? detail.videoInfo?.currentEpisode ?? detail.shortPlayInfo?.episode ?? detail.shortPlayInfo?.currentEpisode; if (episode != null && episode > 0) return episode; return 1; } int _episodeTotal(SkywoodDetail detail) { return detail.episodeList.length; } } class _ImageThumbShape extends SliderComponentShape { final String imagePath; final double imageSize = 16; const _ImageThumbShape({required this.imagePath}); @override Size getPreferredSize(bool isEnabled, bool isDiscrete) { return Size(imageSize, imageSize); } @override void paint( PaintingContext context, Offset center, { required Animation activationAnimation, required Animation enableAnimation, required bool isDiscrete, required TextPainter labelPainter, required RenderBox parentBox, required SliderThemeData sliderTheme, required TextDirection textDirection, required double value, required double textScaleFactor, required Size sizeWithOverflow, }) { // 🔥 使用 Image 直接渲染图片 final imageWidget = Image.asset( imagePath, width: imageSize, height: imageSize, ); // 使用 WidgetsBinding 确保图片已加载 final image = imageWidget.image; final stream = image.resolve(ImageConfiguration()); // 添加监听器,但只在图片加载完成后绘制 stream.addListener( ImageStreamListener( (imageInfo, _) { final rect = Rect.fromCenter( center: center, width: imageSize, height: imageSize, ); context.canvas.drawImageRect( imageInfo.image, Rect.fromLTWH( 0, 0, imageInfo.image.width.toDouble(), imageInfo.image.height.toDouble(), ), rect, Paint(), ); }, onError: (error, stackTrace) { // 🔥 如果图片加载失败,绘制一个默认圆点 final paint = Paint() ..color = Colors.white ..style = PaintingStyle.fill; context.canvas.drawCircle(center, imageSize / 2, paint); }, ), ); } }