// ignore_for_file: use_build_context_synchronously, file_names import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:hgdj/assets_tool/app_colors.dart'; import 'package:hgdj/assets_tool/images.dart'; import 'package:hgdj/hj_page/video/video_logic.dart'; import 'package:hgdj/hj_page/video/view/video_ad_widget.dart'; import 'package:hgdj/hj_page/video/view/video_mask_buy_view.dart'; import 'package:hgdj/hj_page/video/view/video_menu_view.dart'; import 'package:hgdj/hj_page/video/view/video_status_view.dart'; import 'package:hgdj/hj_page/video/view/video_tabbar_menu_widget.dart'; import 'package:hgdj/hj_page/video/view/vip_promo_banner.dart'; import 'package:hgdj/hj_utils/codec_support.dart'; import 'package:hgdj/tools_base/toast.dart'; import 'package:hgdj/tools_base/unique_tag_mixin.dart'; import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; import 'package:video_player/video_player.dart'; import '../../hj_model/video_model.dart'; import '../../tools_base/widget/net_image_widget.dart'; import '../comment/comment_views.dart'; import 'cartoon/video_detail_cartoon_view.dart'; import 'view/video_detail_view.dart'; class VideoPage extends StatefulWidget { final bool isCartoon; final VideoModel? model; final String? id; final String? videoCover; VideoPage({ super.key, this.model, this.isCartoon = false, this.id, this.videoCover, }); @override State createState() => _VideoPageState(); } class _VideoPageState extends State with UniqueTagMixin { @override Widget build(BuildContext context) { return GetBuilder( init: VideoLogic( vid: widget.id ?? widget.model?.id, isCartoon: widget.isCartoon, ), tag: uniqueTag, builder: (logic) => PopScope( // 场景3:长视频返回挽留。iOS 侧滑在 canPop=false 时会被整体禁用且不回调,没法用来弹窗, // 故 iOS 恒可退、仅靠返回箭头(onBackPressed)弹挽留;Android 用 canPop 拦硬件返回键。 canPop: GetPlatform.isIOS || logic.canPop, onPopInvokedWithResult: (didPop, _) { if (didPop) return; logic.showBackGuide(); }, child: Scaffold( body: SafeArea( child: Column( children: [ AspectRatio( aspectRatio: logic.normalRatio, child: _buildPlayContent(logic), ), if (logic.videoModel != null) ...[ VipPromoBanner( playCtr: logic.playerCtr, videoModel: logic.videoModel, ), VideoTabbarMenuWidget( logic.tabCtr, model: logic.videoModel, onSwitchLine: logic.onSwitchLine, ), Expanded(child: _buildTabDetailInfo(logic)), ], ], ), ), ), ), ); } /// 底部 TabBarView:第 0 页详情/推荐,第 1 页评论(与 VideoTabbarMenuWidget 共用 logic.tabCtr) /// 注:选哪个详情视图、切视频走哪条接口,都以当前数据的 videoType 为准,别用页面入参 widget.isCartoon Widget _buildTabDetailInfo(VideoLogic logic) { final model = logic.videoModel; final isCartoonVideo = model?.videoType == 1; return TabBarView( controller: logic.tabCtr, children: [ if (isCartoonVideo) VideoDetailCartoonView( model: model, currentEpisode: logic.currentEpisode, playCtr: logic.playerCtr, vmCallback: logic.switchVideo, episoCallback: logic.switchEpisode, ).keepAlive else VideoDetailView( model: model, playCtr: logic.playerCtr, vmCallback: logic.switchVideo, ).keepAlive, Container( padding: const EdgeInsets.only(top: 12), child: CommentView( // key 跟着 id 走:页内切内容后强制重建,否则 GetBuilder 只在 initState 注册一次 controller, // objId 变了评论区还是上一部的(推荐区靠 ValueKey('rec_$id') 解决的是同一个问题) key: ValueKey('cmt_${model?.id}'), model?.id ?? '', objType: isCartoonVideo ? "cartoon" : "video", ), ).keepAlive, ], ); } /// 播放器外层:监听 videoModel.id 变化,切换视频时触发 fade + scale 动画 Widget _buildPlayContent(VideoLogic logic) { return AnimatedSwitcher( duration: const Duration(milliseconds: 500), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, transitionBuilder: (child, anim) => FadeTransition( opacity: anim, child: ScaleTransition( scale: Tween(begin: 0.9, end: 1.0).animate(anim), child: child, ), ), // KeyedSubtree 的 key 由 videoId 决定:id 变化 → AnimatedSwitcher 触发动画 child: KeyedSubtree( key: ValueKey(logic.videoModel?.id ?? 'empty'), child: _buildPlayBody(logic), ), ); } /// 播放器主体(视频画面 + 控制层 + 蒙层 + 贴片广告 + 返回按钮) Widget _buildPlayBody(VideoLogic logic) { return Container( color: AppColors.primaryColor, child: Stack( fit: StackFit.expand, children: [ GestureDetector( onTap: () => _togglePlayPause(logic), child: Container( alignment: Alignment.center, child: AspectRatio( aspectRatio: logic.videoRatio, child: logic.playerCtr != null ? VideoPlayer(logic.playerCtr!) : SizedBox(), ), ), ), if (logic.playerCtr?.value.isInitialized != true) NetworkImageLoader( imageUrl: logic.videoModel?.cover ?? widget.videoCover ?? "", borderRadius: 0), if (logic.playerCtr?.value.isInitialized == true) ...[ Positioned( top: 12, right: 16, child: VipFreeTipView( key: ValueKey("status_top_${logic.videoModel?.id}"), videoModel: logic.videoModel, ), ), VideoMenuView( key: ValueKey("VideoMenuView_${logic.videoModel?.id}"), playCtr: logic.playerCtr!, videoModel: logic.videoModel, onBuyEvent: logic.showBuyMask, onFullScreen: logic.toFullPage, ), ], Positioned.fill(child: _bufferLoading(logic)), Positioned.fill( child: Obx( () => Visibility( visible: logic.isShowBuy.value, child: VideoMaskBuyView( logic.videoModel, playCtr: logic.playerCtr, // 购买成功要重建播放器换成正片,否则画面停在预览片(要退出重进才正常) onBuySucc: () => logic.onBuySuccess(), ), ), ), ), if (logic.isShowAd && logic.adsList.isNotEmpty) VideoAdWidget( adsInfos: logic.adsList, showBackArrow: false, onFinish: () { logic.setAdShowing(false); logic.update(); logic.play(); }, onToVip: () async { logic.clickAdToVip(); }, ), Align( alignment: Alignment.topLeft, child: GestureDetector( onTap: () => logic.onBackPressed(), child: Container( color: Colors.transparent, width: 30, height: 30, alignment: Alignment.centerLeft, margin: EdgeInsets.only(top: 10, left: 18.w), child: Image.asset( "back_circle.png".commonImgPath, width: 24, ), ), ), ), if (kDebugMode) _buildDebugCodecTag(logic), ], ), ); } /// 缓冲中的居中转圈。直接监听 playCtr(本身是 ValueNotifier), /// 切视频/切线路换了控制器时 ValueListenableBuilder 会自动改订阅,无需手动 add/removeListener Widget _bufferLoading(VideoLogic logic) { final ctr = logic.playerCtr; if (ctr == null) return const SizedBox(); return ValueListenableBuilder( valueListenable: ctr, builder: (_, value, __) => value.isInitialized && value.isBuffering ? const Center( child: CupertinoActivityIndicator( color: AppColors.actionRed, radius: 15)) : const SizedBox(), ); } /// 【仅 debug】左下角编码排查标签:显示当前走 H265/H264,点击复制播放链接 Widget _buildDebugCodecTag(VideoLogic logic) { final isH265 = logic.isPlayingH265; final deviceSupport = CodecSupport.useH265; // 设备硬解 265 探测结果 return Positioned( left: 10, bottom: 35, child: GestureDetector( onTap: () { final url = logic.videoUrl ?? ''; Clipboard.setData(ClipboardData(text: url)); showToast('已复制播放链接'); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: .6), borderRadius: BorderRadius.circular(4), ), child: Text( '${isH265 ? "H265" : "H264"} 设备265:${deviceSupport ? "✓" : "✗"}', style: TextStyle( color: isH265 ? const Color(0xff4CAF50) : const Color(0xffFFC107), fontSize: 11, ), ), ), ), ); } /// 点击播放器:播放中则暂停,否则播放(ctr 为 null 时不操作) void _togglePlayPause(VideoLogic logic) { final ctr = logic.playerCtr; if (ctr == null) return; if (ctr.value.isPlaying) { ctr.pause(); } else { ctr.play(); } } }