Files
huangguo_android/lib/hj_page/video/video_page.dart
T
2026-09-15 15:44:13 +07:00

297 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
}
}