297 lines
10 KiB
Dart
297 lines
10 KiB
Dart
// 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<VideoPage> createState() => _VideoPageState();
|
||
}
|
||
|
||
class _VideoPageState extends State<VideoPage> with UniqueTagMixin {
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GetBuilder<VideoLogic>(
|
||
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<double>(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<VideoPlayerValue>(
|
||
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();
|
||
}
|
||
}
|
||
}
|