初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
//短剧频道的二级 tab:写死这 4 个,顺序与 ShortVideoMainPage 的 TabBarView children 一一对应
const kDramaTabs = ["直播", "抖音", "AI短剧", "热门短剧"];
//默认打开 AI短剧
const kDramaDefaultTab = 2;
const kDramaHotTab = 3;
/// 短剧频道的 tab 调度。被 MainPageLogic.skipToLive 和 ShortTopMenu 按类型 Get.find
/// 所以必须保持全局注册、不能加 tag
class ShortVideoMainLogic extends GetxController with GetSingleTickerProviderStateMixin {
// ===== 外部传入 =====
//只是「进来时停在哪个 tab」,当前 tab 一律读 tabIndex,别混
final int _initTab;
ShortVideoMainLogic({required int index}) : _initTab = index;
// ===== 交互态 =====
final tabIndex = kDramaDefaultTab.obs; // 当前 tab,背景图跟着它切
// ===== Controller =====
late final TabController tabCtr;
//初始下标在这里定而不在构造函数里:init: 是构造参数,每次 rebuild 都会 new 一个再丢掉,
//构造函数里干活等于每帧白跑一遍;onInit 由 GetBuilder 只调一次
@override
void onInit() {
super.onInit();
tabIndex.value = _initTab >= 0 ? _initTab.clamp(0, kDramaTabs.length - 1) : kDramaDefaultTab;
tabCtr = TabController(length: kDramaTabs.length, vsync: this, initialIndex: tabIndex.value)
..addListener(_onTabChanged);
}
// 子 tab 切换只同步下标(背景图跟着变),播放由各播放器按可见性自理
void _onTabChanged() => tabIndex.value = tabCtr.index;
void jumpToLive() => tabCtr.index = 0;
//GetX 生命周期是 onClose(不是 dispose),统一在此释放,避免监听/TabController 泄漏
@override
void onClose() {
tabCtr
..removeListener(_onTabChanged)
..dispose();
super.onClose();
}
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_page/short_video/short_main_video_logic.dart';
import 'package:hgdj/hj_page/short_video/short_video_page.dart';
import 'package:hgdj/hj_page/short_video/view/short_top_menu.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import '../drama/drama_feed_page.dart';
import '../drama/drama_list_page.dart';
import '../live/live_main_page.dart';
/// 短剧频道:直播 / 抖音 / AI短剧 / 热门短剧
class ShortVideoMainPage extends StatelessWidget {
/// 指定初始 tab<0(默认) 表示用 [kDramaDefaultTab]
final int index;
const ShortVideoMainPage({super.key, this.index = -1});
@override
Widget build(BuildContext context) {
return GetBuilder<ShortVideoMainLogic>(
init: ShortVideoMainLogic(index: index),
builder: (logic) => Stack(
fit: StackFit.expand,
children: [
//只有直播 tab 铺这张背景图。Positioned 必须是 Stack 的直接子节点,Obx 只包内部
Positioned.fill(
child: Obx(
() => logic.tabIndex.value == 0
? Image.asset('live_main_bg2.webp'.livePath,
alignment: Alignment.topCenter, fit: BoxFit.cover)
: logic.tabIndex.value == kDramaHotTab
? const ColoredBox(color: Color(0xFF05000E))
: const SizedBox.shrink(),
),
),
SafeArea(
bottom: false,
child: Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
child: TabBarView(
controller: logic.tabCtr,
physics: const NeverScrollableScrollPhysics(),
//顺序必须与 kDramaTabs 一致。直播/热门短剧是普通页面,要给顶部菜单让位;两个信息流页自己铺满
children: [
const Padding(
padding: EdgeInsets.only(top: 44),
child: LiveMainPage()),
ShortVideoPage(
type: VideoListType.RECOMMEND,
tabIndex: 1,
superTabCtr: logic.tabCtr)
.keepAlive,
DramaFeedPage(tabIndex: 2, superTabCtr: logic.tabCtr)
.keepAlive,
const Padding(
padding: EdgeInsets.only(top: 44),
child: DramaListPage())
.keepAlive,
],
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: ShortTopMenu(tabCtr: logic.tabCtr)),
],
),
),
],
),
);
}
}
@@ -0,0 +1,191 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_page/short_video/short_video_page.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/api_service/vid_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/history_util.dart';
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
import 'package:hgdj/tools_base/ad_manager.dart';
import '../../hj_model/splash/ads_model.dart';
import '../../hj_model/video_model.dart';
import '../../tools_base/debug_log.dart';
class ShortVideoLogic extends GetxController {
// GetBuilder 局部刷新 id:当前页变化时只刷信息流
static const idFeed = 'feed';
bool isFirstLoaded = false; // 首屏是否加载完成(false 时显示 loading),变化走整页 update()
bool isLoadingData = false; // 显示倒数第6个视频的时候,主动翻页加载数据,isLoadingData防网络去重的作用
int pageViewIndex = 0; // 显示的当前页数
int currentPage = 1;
bool hasNext = true;
List<VideoModel>? videoList;
late PageController pageCtr;
final ValueNotifier<bool> isScrolling = ValueNotifier(false); // 上下滑动切视频时隐藏进度条
final VideoListType listType;
final TabController? superTabCtr; // 外层主 tab,用于切到本 tab 时播放/离开时暂停
final int? tabIndex;
VideoModel? secondVideoModel; //入参视频详情数据(需要通过视频详情接口获取视频权限)
late StreamSubscription _followSub;
List<AdsInfoModel> get insertAdList => AdManager().adsByType(12);
ShortVideoLogic({
this.currentPage = 1,
this.hasNext = true,
this.videoList,
int pageIndex = 0,
this.listType = VideoListType.SECOND,
this.superTabCtr,
this.tabIndex,
}) {
pageViewIndex = pageIndex;
}
@override
void onInit() {
_followSub = eventBus.on<CollectStatusModel>(_followListenCallback);
pageCtr = PageController(initialPage: pageViewIndex);
superTabCtr?.addListener(_listenTabIndex);
super.onInit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
if (listType == VideoListType.SECOND) {
if (videoList?.length == 1) {
await _loadVideoDetail();
} else {
_markLoaded();
}
}
initData();
});
}
// 外层 tab 切换:首次切到本 tab 且无数据则加载(播/停由各播放器按可见性自理)
void _listenTabIndex() {
if (superTabCtr?.index == tabIndex && videoList?.isEmpty == true) {
initData();
}
}
// 首屏加载完成:切 loading→内容,整页刷新
void _markLoaded() {
isFirstLoaded = true;
update();
}
void _followListenCallback(CollectStatusModel model) {
// 当前ui状态通过CollectStatusWrapper已经处理了
// 这里处理列表数据(只需要改数据),不需要刷新ui
for (final video in videoList ?? <VideoModel>[]) {
if (model.isFollowed != null && model.uid == video.publisher?.uid) {
video.publisher?.hasFollowed = model.isFollowed;
}
}
}
Future<void> _loadVideoDetail() async {
try {
VideoModel? videoModel =
await VidService.getDetail(videoList?.first.id ?? '');
if (videoModel != null) {
secondVideoModel = videoModel;
videoList?.removeAt(0);
videoList?.insert(0, videoModel);
}
} catch (e) {
debugLog(e.toString());
}
_markLoaded();
}
void initData() {
if (listType == VideoListType.SECOND) {
_markLoaded();
return;
}
isFirstLoaded = false;
update();
loadData(isInit: true);
}
Future<void> loadData(
{int page = 1, int pageSize = 12, bool isInit = false}) async {
if (listType == VideoListType.SECOND) {
return;
}
// 推荐列表:是否还能翻页以 hasNext 为准,不再用 totalPages
if (page > 1 && !hasNext) return;
if (isLoadingData) return;
try {
isLoadingData = true; // 进入请求即上锁,防止快速翻页/刷新重复拉取
List<VideoModel> list = [];
try {
if (listType == VideoListType.FOLLOW) {
final resp = await CommonService.fetchFollowDynamics(
page: page, size: pageSize, type: 1);
list = resp?.videos ?? [];
hasNext = resp?.hasNext ?? false;
} else {
// 推荐:固定 pageSize=20,只传 pageNumber/pageSize
final resp = await VidService.getRecommendList(page, pageSize: 20);
debugLog('short recommend queueVersion=${resp?.queueVersion}');
list = resp?.vInfo ?? [];
hasNext = resp?.hasNext ?? false;
}
} catch (e) {
debugLog(e);
}
currentPage = page;
videoList ??= [];
if (page == 1) {
videoList = [];
if (isInit && secondVideoModel != null) {
videoList?.add(secondVideoModel!);
}
}
videoList?.addAll(list);
if (listType == VideoListType.RECOMMEND) {
videoList?.removeWhere((element) => element.isRandomAd());
AdManager().insertRandomAds(videoList ?? [], insertAdList, adGap: 6);
}
} catch (e) {
videoList ??= [];
debugLog("短视频列表获取失败:$e");
}
isLoadingData = false;
_markLoaded();
}
void onPageChanged(int index) {
if (pageViewIndex != index) {
pageViewIndex = index;
update([idFeed]); // 当前页变化,只刷信息流(各 item 重算 isCurrentPage)
// 滑到即记:不看播放权限、不看播放器是否创建成功、不看播放进度,只要滑到这一帧就入历史。
// 广告位跳过;HistoryUtil.insert 内部按 id 去重(已有则刷新到最前),所以重复滑无副作用。
final list = videoList;
if (list != null && index >= 0 && index < list.length) {
final video = list[index];
if (!video.isRandomAd()) {
HistoryUtil.insert(video, MediaStyle.ShortVideo);
}
}
}
if (hasNext && videoList!.length - index <= 6) {
loadData(page: currentPage + 1);
}
}
// GetX 释放只走 onClose(),不会调用 dispose(),清理必须放这里
@override
void onClose() {
_followSub.cancel();
eventBus.off(_followSub);
superTabCtr?.removeListener(_listenTabIndex);
pageCtr.dispose();
isScrolling.dispose();
super.onClose();
}
}
@@ -0,0 +1,146 @@
// ignore_for_file: constant_identifier_names
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/short_video/short_video_logic.dart';
import 'package:hgdj/hj_page/short_video/view/feed_ad_view.dart';
import 'package:hgdj/hj_page/short_video/view/short_video_player.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
/// SECOND 短视频只有一个视频(入参视频需要通过视频详情接口获取权限),需要加载推荐列表数据;
/// SECONDList 传过来的不止一个视频,不需要加载推荐列表数据(列表视频数据,都需要视频详情接口获取权限);
enum VideoListType { FOLLOW, RECOMMEND, SECOND }
class ShortVideoPage extends StatefulWidget {
final VideoListType type;
final TabController? superTabCtr;
final int? tabIndex; //
final List<VideoModel>? videoList;
final int pageIndex;
const ShortVideoPage({
super.key,
this.type = VideoListType.SECOND,
this.superTabCtr,
this.tabIndex,
this.videoList,
this.pageIndex = 0,
});
@override
State<StatefulWidget> createState() {
return _ShortVideoPageState();
}
}
class _ShortVideoPageState extends State<ShortVideoPage> with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<ShortVideoLogic>(
tag: uniqueTag, // 可重复 push + 多 tab 并存,按实例隔离
init: ShortVideoLogic(
listType: widget.type,
pageIndex: widget.pageIndex,
videoList: widget.videoList,
superTabCtr: widget.superTabCtr,
tabIndex: widget.tabIndex,
),
builder: _buildBody, // 整页 update()loading / 错误 / 信息流 三态切换
);
}
Widget _buildBody(ShortVideoLogic logic) {
final Widget content;
if (!logic.isFirstLoaded) {
content = const LoadingCenterWidget();
} else if (logic.videoList?.isNotEmpty != true) {
content = CErrorWidget(retryOnTap: logic.initData);
} else {
content = _buildFeed(logic);
}
return Material(color: Colors.transparent, child: content);
}
/// 短视频信息流:竖向 PageView + (SECOND)返回按钮(进度条已内嵌到各 ShortVideoPlayer
Widget _buildFeed(ShortVideoLogic logic) {
return Stack(
fit: StackFit.expand,
children: [
RefreshIndicator(
onRefresh: () => logic.loadData(),
child: _pageView(logic),
),
if (widget.type == VideoListType.SECOND) _backButton(),
],
);
}
/// 信息流局部刷新:当前页变化时只 update([idFeed]),各 item 重算 isCurrentPage
Widget _pageView(ShortVideoLogic logic) {
return GetBuilder<ShortVideoLogic>(
tag: uniqueTag,
id: ShortVideoLogic.idFeed,
// 上下滑动开始/结束 → 切换 isScrolling,各播放器据此隐藏/显示进度条
builder: (logic) => NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n is ScrollStartNotification) {
logic.isScrolling.value = true;
} else if (n is ScrollEndNotification) {
logic.isScrolling.value = false;
}
return false;
},
child: PageView.builder(
onPageChanged: logic.onPageChanged,
physics: const ClampingScrollPhysics(),
controller: logic.pageCtr,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
itemCount: logic.videoList?.length ?? 0,
itemBuilder: (context, index) => _pageItem(logic, index),
),
),
);
}
/// 单页:广告位 or 视频播放器(进度条由播放器自管)
Widget _pageItem(ShortVideoLogic logic, int index) {
final VideoModel videoInfo = logic.videoList![index];
if (videoInfo.isRandomAd()) {
return FeedAdView(mediaInfo: videoInfo);
}
return ShortVideoPlayer(
key: ValueKey(videoInfo.id),
index: index,
isCurrentPage: logic.pageViewIndex == index,
videoInfo: videoInfo,
listType: widget.type,
isScrolling: logic.isScrolling,
);
}
Widget _backButton() {
return Positioned(
top: 20,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.only(top: 12),
child: IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black, offset: Offset(1, 1), blurRadius: 1),
],
),
onPressed: () => Get.back(),
),
),
),
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/cupertino.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/routers/jump_router.dart';
import '../../../hj_model/splash/ads_model.dart';
import '../../../tools_base/widget/net_image_widget.dart';
/// 短视频信息流里的整屏图片广告位(PageView item),点击跳落地页
class FeedAdView extends StatelessWidget {
final VideoModel? mediaInfo;
const FeedAdView({super.key, this.mediaInfo});
@override
Widget build(BuildContext context) {
final AdsInfoModel? adv = mediaInfo?.randomAdsInfo;
return GestureDetector(
onTap: () => pushToPageByLink(adv?.href),
// 强制全屏铺满(与视频页一致),避免 iOS 上未撑满导致显示不全
child: SizedBox.expand(
child: NetworkImageLoader(
imageUrl: adv?.cover ?? "",
borderRadius: 0,
width: double.infinity,
height: double.infinity,
fit: BoxFit.fill,
),
),
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/splash/ads_model.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/ad_manager.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
/// 短视频左上角浮动广告(position=8)
class FloatAdView extends StatefulWidget {
const FloatAdView({super.key});
@override
State<FloatAdView> createState() => _FloatAdViewState();
}
class _FloatAdViewState extends State<FloatAdView> {
static bool _isDismissed = false; // 关闭后本次启动内所有短视频页都不再展示
late final AdsInfoModel? _adInfo = AdManager().adsByType(8).firstOrNull;
@override
Widget build(BuildContext context) {
final ad = _adInfo; // 取局部变量做类型提升,后续无需 ! 断言
if (_isDismissed || ad == null || !AdManager().showAbTestAd)
return const SizedBox.shrink();
final cover = ad.cover;
if (cover == null || cover.isEmpty) return const SizedBox.shrink();
return Positioned(
top: Get.mediaQuery.padding.top + 45,
left: 16,
child: SizedBox(
width: 70,
height: 70,
child: Stack(
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () => pushToPageByLink(ad.href),
child: NetworkImageLoader(
imageUrl: cover,
width: 70,
height: 70,
fit: BoxFit.cover,
borderRadius: 4,
),
),
Positioned(
top: 2,
right: 2,
child: GestureDetector(
onTap: () => setState(() => _isDismissed = true),
child: Container(
width: 18,
height: 18,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
size: 12,
color: Color(0xff141414),
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,359 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/tools_base/video_download/download_button.dart';
import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart';
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:uuid/uuid.dart';
import 'package:video_player/video_player.dart';
import '../../../alert/video/share_media_dialog.dart';
import '../../../alert/video/video_line_menu_alert.dart';
import '../../../hj_utils/api_service/mine_service.dart';
import '../../../tools_base/debug_log.dart';
import '../../../tools_base/global_store/store.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../comment/comment_alert.dart';
import '../../home/widget/collect_status_wrapper.dart';
import '../../user_center_page/user_center_page.dart';
/// 短视频 / 短剧共用的右侧动作菜单:点赞、收藏、评论、分享、下载、线路、收起。
/// 两者只差三处,全部由 [style] 分流:
/// - [MediaStyle.ShortVideo]:多「作者头像+关注」,互动与下载对象都是这条视频
/// - [MediaStyle.Drama]:没有作者人设,互动对象是**整部剧**(不是当前这一集),
/// 但下载对象是**当前这一集**;分享完还要回传服务端累计互动数(推荐分要吃)
class MediaActionMenu extends StatefulWidget {
final MediaStyle style;
final VideoModel? videoModel;
final VideoPlayerController? playerCtr;
final VoidCallback? onSwitchLine; //切换 CDN 线路后重新起播
const MediaActionMenu({
super.key,
required this.style,
this.videoModel,
this.playerCtr,
this.onSwitchLine,
});
@override
State<MediaActionMenu> createState() => _MediaActionMenuState();
}
class _MediaActionMenuState extends State<MediaActionMenu>
with TickerProviderStateMixin {
bool _isFollowLoading = false;
bool _isCollectLoading = false;
bool _isLikeLoading = false;
/// 关注成功后 + 号「旋转一圈 + 缩小消失」动画,只有短视频有关注按钮,短剧不建。
/// 不能写成 late final:短剧路径下全程不访问它,dispose() 里那句释放会成为「首次访问」,
/// 于是在 unmount 阶段才去 createTicker → TickerMode.of(context) → 查已 deactivate 的祖先直接抛异常
AnimationController? _followAniCtr;
//局部刷新信号:点赞/收藏/关注各刷各的,避免整条菜单重建
final ValueNotifier<int> _likeTick = ValueNotifier(0);
final ValueNotifier<int> _collectTick = ValueNotifier(0);
final ValueNotifier<int> _followTick = ValueNotifier(0);
//各 item 之间的统一间距
int get itemGap => 10;
bool get _isDrama => widget.style == MediaStyle.Drama;
VideoModel? get _video => widget.videoModel;
//互动对象:短剧全部挂在剧上,不是当前这一集
String? get _objId => _isDrama ? _video?.dramaInfo?.id : _video?.id;
//点赞接口的业务类型
String get _likeType => _isDrama ? 'drama' : 'SHORT';
//评论接口的对象类型(和点赞不同口径:短视频这里是 video 不是 SHORT)
String get _commentType => _isDrama ? 'drama' : 'video';
bool get isFollow => _video?.publisher?.hasFollowed ?? false;
bool get isMy => globalStore.isMe(_video?.publisher?.uid);
bool get isCollect => _video?.vidStatus?.hasCollected ?? false;
bool get isLike => _video?.vidStatus?.hasLiked ?? false;
@override
void initState() {
super.initState();
VideoMenuSwitch.addListen(_menuListen);
if (!_isDrama) {
_followAniCtr = AnimationController(
duration: const Duration(milliseconds: 500), vsync: this);
}
}
@override
void dispose() {
VideoMenuSwitch.removeLister(_menuListen);
_followAniCtr?.dispose();
_likeTick.dispose();
_collectTick.dispose();
_followTick.dispose();
super.dispose();
}
void _menuListen(bool isShow) {
if (mounted) setState(() {});
}
//局部刷新:只重建依赖该信号的子树(点赞/收藏/关注互不牵连)
Widget _refreshOn(ValueNotifier<int> tick, Widget Function() builder) =>
ValueListenableBuilder<int>(
valueListenable: tick, builder: (_, __, ___) => builder());
@override
Widget build(BuildContext context) {
final bool show = VideoMenuSwitch.isShow;
return SizedBox(
width: 46,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 动作区:显隐淡入淡出 + 右滑;隐藏时保留占位(眼睛按钮位置不跳动)且不可点击,
// 子项常驻避免反复重建/头像重载
MenuReveal(
visible: show,
offset: const Offset(0.35, 0), // 隐藏时向右滑出
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//短剧没有作者人设,不出头像和关注
if (!_isDrama) ...[
_followItem(),
itemGap.sizeBoxH,
],
_refreshOn(
_likeTick,
() => menuActionItem(
(_video?.likeCount ?? 0).countStr,
isLike
? 'like_red.png'.commonImgPath
: 'like_white.png'.commonImgPath,
onTap: _onLikeEvent,
),
),
itemGap.sizeBoxH,
menuActionItem(
_video?.commentCount.countOr("评论") ?? "评论",
'comment.png'.videoPath,
onTap: _onCommentEvent,
),
itemGap.sizeBoxH,
_refreshOn(
_collectTick,
() => menuActionItem(
isCollect ? "已收藏" : "收藏",
isCollect
? 'collect_red.png'.commonImgPath
: 'collect_white.png'.commonImgPath,
onTap: _onCollectEvent,
),
),
itemGap.sizeBoxH,
//顺序按设计稿:下载在分享前面。
//短剧显式传 style,让缓存记录落进短剧桶(不传会按 isShort 归到短视频,
//在「我的缓存」里就会跑到抖音 tab 下)
DownloadButton(
video: _video,
isShort: true,
style: _isDrama ? MediaStyle.Drama : null,
),
itemGap.sizeBoxH,
menuActionItem('分享', 'share_arrow.png'.videoPath,
onTap: _onShareEvent),
itemGap.sizeBoxH,
menuActionItem(
'切换',
"line_white2.png".videoPath,
onTap: () async {
if (await VideoLineMenuAlert.show())
widget.onSwitchLine?.call();
setState(() {});
},
),
itemGap.sizeBoxH,
],
),
),
// 眼睛按钮常驻:仅切换图标(eye_open/eye_close)menuActionItem 内置 AnimatedSwitcher 交叉淡入
menuActionItem(
"", (show ? "eye_open.png" : "eye_close.png").videoPath,
onTap: () {
//广播给本页其他播放器/进度条,再刷新自身
VideoMenuSwitch.toggle();
setState(() {});
}),
],
),
);
}
//作者头像 + 关注 + 号(仅短视频)
Widget _followItem() {
return Stack(
alignment: Alignment.bottomCenter,
children: [
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(() => UserCenterPage(uid: _video?.publisher?.uid ?? 0)),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: NetworkImageLoader(
imageUrl: _video?.publisher?.portrait ?? '',
width: 46,
height: 46,
borderRadius: 30,
),
),
),
InkWell(
enableFeedback: false,
onTap: _onFollowEvent,
child: _refreshOn(
_followTick,
() => CollectStatusWrapper(
videoModel: _video, builder: _buildFollowPlus),
),
),
],
);
}
//关注 + 号:未关注时静态显示;关注成功后转一圈缩小消失;动画结束隐藏
Widget _buildFollowPlus() {
final ctr = _followAniCtr;
if (ctr == null || (isFollow && !ctr.isAnimating)) {
return const SizedBox(height: 16);
}
return RotationTransition(
turns: ctr,
child: ScaleTransition(
scale: Tween<double>(begin: 1, end: 0).animate(
CurvedAnimation(
parent: ctr,
curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
),
child: Image.asset('add_red.webp'.videoPath, width: 16, height: 16),
),
);
}
// ============ 互动 ============
//短剧收藏走书架接口(对象是剧),短视频走通用收藏
void _onCollectEvent() async {
if (_isCollectLoading) return;
_isCollectLoading = true;
try {
final preCollect = isCollect;
if (_isDrama) {
await (preCollect
? ACGService.deleteBookshelf(_objId ?? '')
: ACGService.addBookshelf(_objId ?? ''));
} else {
await MineService.postCollect(_objId, "SHORT", !preCollect);
}
_video?.collectCount = (_video?.collectCount ?? (preCollect ? 1 : 0)) +
(preCollect ? -1 : 1);
_video?.vidStatus?.hasCollected = !preCollect;
if (!preCollect) showToast("收藏成功");
_collectTick.value++;
} catch (e) {
debugLog('收藏失败', e.toString()); // 吞掉异常,只复位 loading
} finally {
_isCollectLoading = false;
}
}
void _onLikeEvent() async {
if (_isLikeLoading) return;
_isLikeLoading = true;
try {
final preLike = isLike;
if (preLike) {
await CommonService.cancelLike(_objId, _likeType);
} else {
await CommonService.sendLike(_objId, _likeType);
showToast("已点赞");
}
_video?.likeCount =
(_video?.likeCount ?? (preLike ? 1 : 0)) + (preLike ? -1 : 1);
_video?.vidStatus?.hasLiked = !preLike;
_likeTick.value++;
} catch (e) {
debugLog('点赞失败', e.toString());
} finally {
_isLikeLoading = false;
}
}
void _onCommentEvent() async {
final isPlaying = widget.playerCtr?.value.isPlaying ?? false;
widget.playerCtr?.pause();
await showCommentDialog(
_objId ?? '',
objType: _commentType,
commentCount: _video?.commentCount,
);
if (isPlaying) widget.playerCtr?.play();
}
void _onShareEvent() async {
final isPlaying = widget.playerCtr?.value.isPlaying ?? false;
widget.playerCtr?.pause();
await Get.dialog(ShareMediaDialog(videoModel: _video));
//短剧分享要回服务端累计互动数(推荐分要吃),eventId 幂等:同一次分享重试沿用同一个
if (_isDrama) {
DramaService.shareOutput(
content: globalStore.meInfo?.promoteURL ?? '',
mediaId: _objId,
contentId: _video?.dramaEpisode?.id,
eventId: const Uuid().v4(),
);
}
if (isPlaying) widget.playerCtr?.play();
}
void _onFollowEvent() async {
if (isMy) {
showToast("自己不能关注自己");
return;
}
if (_isFollowLoading) return;
_isFollowLoading = true;
try {
final followStatus = !isFollow;
await MineService.getFollow(_video?.publisher?.uid ?? 0, followStatus);
_video?.publisher?.hasFollowed = followStatus;
if (followStatus) {
//关注成功:+ 号转一圈缩小消失,结束后重建隐藏
_followAniCtr?.forward(from: 0).whenComplete(() {
if (mounted) _followTick.value++;
});
} else {
_followAniCtr?.reset(); //取消关注:+ 号复位显示
}
showToast(followStatus ? "关注成功" : "已取消关注");
} catch (e) {
debugLog('getFollow', e.toString());
}
_isFollowLoading = false;
if (mounted) _followTick.value++;
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import '../../home/search_page/search_main_page.dart';
import '../short_main_video_logic.dart';
class ShortTopMenu extends StatelessWidget {
final TabController? tabCtr;
const ShortTopMenu({super.key, this.tabCtr});
@override
Widget build(BuildContext context) {
// 选中态、下划线位移全交给 TabBar 自己按 tabCtr 动画,不用 Obx 整条重建
//高度 48 与 ShortVideoMainPage 里两个普通 tab 的 Padding(top: 44) 对应,改一处要同步
return Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
24.sizeBoxW,
Expanded(
child: TabBar(
//渐变下划线:宽 16 高 4 贴底,色值同 CustomIndicator 默认的 gradientColors
indicator: CustomIndicator(
width: 16,
height: 4,
isGradient: true,
borderRadius: BorderRadius.circular(2),
),
labelColor: Colors.white.withValues(alpha: 0.9),
labelStyle:
const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
unselectedLabelColor: Colors.white.withValues(alpha: 0.55),
unselectedLabelStyle: const TextStyle(fontSize: 15),
labelPadding: EdgeInsets.zero,
controller: tabCtr,
isScrollable: true,
tabAlignment: TabAlignment.center,
tabs: kDramaTabs.map(_menuItem).toList(),
),
),
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(SearchMainPage(initialResultType: MediaStyle.Drama)),
child: Container(
padding: const EdgeInsets.only(bottom: 2),
child: Image.asset(
'search_icon.png'.commonImgPath,
width: 24,
color: const Color(0xff989898),
),
),
)
],
),
);
}
/// 单个 tab:只画文字(直播原来是图标,改版后统一成文字)。
/// 底部留 7 = 与文字间距 3 + 指示器高 4,腾给贴底绘制的 CustomIndicator
Widget _menuItem(String title) => Container(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 7),
child: Text(title),
);
}
@@ -0,0 +1,384 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/short_video/view/media_action_menu.dart';
import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart';
import 'package:hgdj/hj_page/video/view/long_video_status.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/free_play_manager.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/ad_manager.dart';
import 'package:video_player/video_player.dart';
import '../../../hj_model/splash/watch_count_model.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/global_store/store.dart';
import '../../../tools_base/widget/shrink_wrap.dart';
import '../../home/tag/video_tag_page.dart';
/// 短视频操作台:左侧信息栏 + 右侧动作菜单 + 中间暂停播放按钮
class ShortVideoMenu extends StatefulWidget {
final VideoPlayerController? playerCtr;
final VideoModel? videoModel;
final WatchCount? watchCount;
final VoidCallback? onBuy;
final VoidCallback? onSwitchLine; //切换 CDN 线路后重新起播
const ShortVideoMenu({
super.key,
this.playerCtr,
this.videoModel,
this.watchCount,
this.onBuy,
this.onSwitchLine,
});
@override
State<StatefulWidget> createState() {
return _ShortVideoMenuState();
}
}
class _ShortVideoMenuState extends State<ShortVideoMenu> {
//左侧各 item 之间的统一间距
double get gapValue => 14;
//当前视频的付费/权限状态(已购/需VIP/需金币等)
LongVideoStatus get videoStatus => longVideoStatus(widget.videoModel);
@override
void initState() {
super.initState();
VideoMenuSwitch.addListen(_menuListen);
}
@override
void dispose() {
VideoMenuSwitch.removeLister(_menuListen);
super.dispose();
}
void _menuListen(bool isShow) {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(bottom: 4),
child: Stack(
alignment: Alignment.center,
children: [
Container(
//底部留白避开文字广告条:无广告 40,有广告(type33) 56
padding: EdgeInsets.only(
bottom: AdManager().adsByType(33).isEmpty ? 40 : 56),
child: Row(
children: [
Expanded(
child: MenuReveal(
visible: VideoMenuSwitch.isShow,
offset: const Offset(-0.1, 0), // 隐藏时向左滑出
child: _buildLeftPanel(),
),
),
const SizedBox(width: 8),
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MediaActionMenu(
style: MediaStyle.ShortVideo,
videoModel: widget.videoModel,
playerCtr: widget.playerCtr,
onSwitchLine: widget.onSwitchLine,
),
],
),
12.sizeBoxW,
],
),
),
_buildCenterPlay(),
],
),
);
}
// ============ 左侧信息栏(金币购买 / VIP 入口 + 作者 / 标题 / 标签)============
Widget _buildLeftPanel() {
return Container(
padding: const EdgeInsets.only(left: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildBuyItem(),
_buildVipItem(),
_buildAuthorItem(),
_buildTitleItem(),
_buildTagItem(),
],
),
);
}
//作者名,点击暂停并进入个人主页
Widget _buildAuthorItem() {
return Container(
padding: EdgeInsets.only(top: gapValue),
child: Row(
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.deferToChild,
onTap: () {
widget.playerCtr?.pause();
pushToPersonCenter(widget.videoModel?.publisher?.uid);
},
child: Text(
"@${widget.videoModel?.publisher?.name ?? ""}",
maxLines: 1,
style: TextStyle(
fontSize: 12,
color: Color(0x73FFFFFF),
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
//视频标题(最多2行,空标题不显示)
Widget _buildTitleItem() {
if ((widget.videoModel?.title?.trim() ?? "").isEmpty) {
return SizedBox();
}
return Container(
padding: EdgeInsets.only(top: gapValue),
child: Text(
widget.videoModel?.title?.trim() ?? "",
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.05),
offset: const Offset(1, 1),
blurRadius: 5,
),
],
),
),
);
}
//金币视频专属:依次展示 已购买 / 已享金币特权 / 免费观影剩余次数 / 金币解锁按钮
Widget _buildBuyItem() {
//自己的作品、免费区、非金币视频 → 不展示
if (globalStore.isMe(widget.videoModel?.publisher?.uid)) {
return const SizedBox();
}
if (widget.videoModel?.freeArea == true) {
return const SizedBox();
}
if (widget.videoModel?.isCoinVideo() != true) {
return const SizedBox();
}
if (widget.videoModel?.vidStatus?.hasPaid == true) {
return Container(
margin: EdgeInsets.only(top: gapValue),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: Color(0xffFF9000),
),
child: const Text(
"已购买",
style: TextStyle(
color: Color(0xff292929),
fontSize: 12,
),
),
);
}
if (videoStatus.type == LongVideoStatusType.coinFree) {
return Container(
margin: EdgeInsets.only(top: gapValue),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(3)),
color: Color(0x99000000),
),
child: Text(
videoStatus.desc,
style: TextStyle(color: Colors.white, fontSize: 12),
),
);
}
if ((widget.watchCount?.coinWatchCount ?? -1) >= 0) {
return Container(
margin: EdgeInsets.only(top: gapValue),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(3)),
color: Color(0x99000000),
),
child: Text(
"免费金币观影数剩余: ${widget.watchCount?.coinWatchCount}",
style: TextStyle(color: Colors.white, fontSize: 12),
),
);
}
return Container(
margin: EdgeInsets.only(top: gapValue),
child: GestureDetector(
onTap: () async {
widget.playerCtr?.pause();
widget.onBuy?.call();
},
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Color(0xffFF9000),
borderRadius: BorderRadius.circular(3),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${widget.videoModel?.coins}金币 解锁",
style: TextStyle(
color: Color(0xff000000),
fontSize: 12,
),
)
],
),
),
),
);
}
//非金币视频的 VIP 入口:依次展示 已享VIP特权 / 免费观看剩余次数 / 开通会员按钮
Widget _buildVipItem() {
//自己的作品、免费区、已购买、金币视频 → 不展示(金币视频走 _buildBuyItem
if (globalStore.isMe(widget.videoModel?.publisher?.uid)) {
return const SizedBox();
}
if (widget.videoModel?.freeArea == true) {
return const SizedBox();
}
if (widget.videoModel?.vidStatus?.hasPaid == true) {
return const SizedBox();
}
if (widget.videoModel?.isCoinVideo() == true) {
return const SizedBox();
}
int watchValue = widget.watchCount?.watchCount ?? 0;
if (FreePlayManager().useFreePlay(widget.videoModel) && watchValue < 0) {
// 免费观看次数内观看的视频, 重新观看,视频是可以观看, 但次数小于0时不显示
return SizedBox();
}
if (videoStatus.type == LongVideoStatusType.vipFree) {
return Container(
margin: EdgeInsets.only(top: gapValue),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(3)),
color: Color(0x99000000),
),
child: Text(
"已享VIP免费特权",
style: TextStyle(color: Colors.white, fontSize: 12),
),
);
}
bool isShowFreeCount = (watchValue >= 0);
return InkWell(
enableFeedback: false,
onTap: () async {
//还有免费次数时按钮仅作展示,不触发购买
if (isShowFreeCount) return;
widget.onBuy?.call();
},
child: Container(
margin: EdgeInsets.only(top: gapValue),
padding: const EdgeInsets.fromLTRB(10, 2, 10, 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
gradient: LinearGradient(
colors: [
isShowFreeCount ? Color(0x1fFF9D00) : Color(0xffFFE580),
isShowFreeCount ? Color(0x1fFF9D00) : Color(0xffFACC15),
],
),
),
child: Text(
isShowFreeCount ? "免费视频剩余${watchValue + 1}" : "开通会员 畅享完整版",
style: TextStyle(
color: isShowFreeCount ? Color(0xffFF9D00) : Color(0xff7B0000),
fontSize: 12),
),
),
);
}
//标签横排(最多1行),点击暂停并进入标签聚合页
Widget _buildTagItem() {
if (widget.videoModel?.tags?.isNotEmpty != true) return const SizedBox();
return Container(
padding: EdgeInsets.only(top: gapValue),
child: _tagWrap(),
);
}
Widget _tagWrap() {
return ShrinkWrap(
spacing: 0,
runSpacing: 6,
maxLines: 1,
children: widget.videoModel?.tags
?.map((e) => InkWell(
enableFeedback: false,
onTap: () {
widget.playerCtr?.pause();
Get.to(
VideoTagPage(e, isShortStyle: true),
preventDuplicates: true,
);
},
child: Container(
padding: EdgeInsets.fromLTRB(8, 3, 8, 3),
margin: EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
"#${e.name}",
style: TextStyle(
color: Color(0xffffffff),
fontSize: 12,
),
),
),
))
.toList() ??
[],
);
}
// ============ 中间播放/暂停按钮(play↔pause 图标形变 + 淡入淡出)============
Widget _buildCenterPlay() {
final ctr = widget.playerCtr;
if (ctr == null) return const SizedBox.shrink();
return CenterPlayPause(controller: ctr);
}
}
@@ -0,0 +1,179 @@
import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/short_video/view/float_ad_view.dart';
import 'package:hgdj/hj_page/short_video/view/short_video_menu.dart';
import 'package:hgdj/hj_page/short_video/view/short_video_player_logic.dart';
import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart';
import 'package:hgdj/hj_page/short_video/view/video_progress_widget.dart';
import 'package:hgdj/hj_page/video/view/video_mask_buy_view.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:video_player/video_player.dart';
import 'package:visibility_detector/visibility_detector.dart';
import '../short_video_page.dart';
class ShortVideoPlayer extends StatefulWidget {
const ShortVideoPlayer({
super.key,
this.isCurrentPage,
required this.index,
required this.videoInfo,
this.listType,
this.isScrolling,
});
final int index;
final VideoModel? videoInfo;
final bool? isCurrentPage;
final VideoListType? listType;
final ValueNotifier<bool>? isScrolling; // 上下滑动切视频时隐藏进度条
@override
State<ShortVideoPlayer> createState() => _ShortVideoPlayerState();
}
// PageView 里 2~3 条同时存活,用 per-实例唯一 tag 隔离各自的播放器 logic
class _ShortVideoPlayerState extends State<ShortVideoPlayer>
with UniqueTagMixin {
//进度条压暗档位:滑动中 / 挂着买量蒙层都用这一档
static const _dimOpacity = 0.4;
//压暗/复原的淡入淡出时长。滑动只在 ScrollStart/ScrollEnd 各通知一次,
//硬跳变正好压在手指刚动和刚松的瞬间,很容易被看成闪烁,给一小段过渡盖住
static const _dimDuration = Duration(milliseconds: 150);
late final ShortVideoPlayerLogic _logic = ShortVideoPlayerLogic(
index: widget.index,
videoInfo: widget.videoInfo,
listType: widget.listType,
);
@override
void didUpdateWidget(covariant ShortVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
// 同 id 换对象(首条拉完详情后 removeAt+insert)时 State 会被复用,logic 必须跟着换
_logic.videoInfo = widget.videoInfo;
_logic.index = widget.index;
// isCurrentPage 由父级每次 rebuild 传入,是流动属性,只能在这里比出「刚滑回本条」
if (widget.isCurrentPage == true && oldWidget.isCurrentPage != true) {
_logic.onBecomeCurrent();
}
}
@override
Widget build(BuildContext context) {
return GetBuilder<ShortVideoPlayerLogic>(
tag: uniqueTag,
init: _logic,
builder: (logic) {
final status = logic.videoStatus;
return VisibilityDetector(
key: logic.visibilityKey,
onVisibilityChanged: logic.onVisibility,
child: GestureDetector(
onTap: logic.togglePlay,
onLongPressUp: logic.resetSpeed,
onLongPressEnd: (_) => logic.resetSpeed(),
onLongPressStart: (_) => logic.speedUp(),
// 用 Material 而非 Scaffold:这里只需要一层透明底 + Material 语境,
// 每个 PageView item 各挂一个 Scaffold 太重
child: Material(
color: Colors.transparent,
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
if (logic.playerCtr?.value.isInitialized == true)
Center(
child: AspectRatio(
aspectRatio: logic.playerCtr!.value.aspectRatio,
child: VideoPlayer(logic.playerCtr!),
),
),
_bufferLoading(logic),
ShortVideoMenu(
videoModel: logic.video,
playerCtr: logic.playerCtr,
watchCount: logic.watchCount,
onBuy: logic.onBuy,
onSwitchLine: logic.onSwitchLine,
),
FullScreenButton(logic: logic),
if (widget.isCurrentPage == true) const FloatAdView(),
if (status.isNeedPay) // vip用遮罩, 金币用弹窗
Positioned.fill(
child: Visibility(
visible: logic.showBuyMask,
child: VideoMaskBuyView(
logic.video,
maskTitle: logic.maskTitle,
playCtr: logic.playerCtr,
onBuySucc: logic.onBuySuccess,
),
),
),
// 进度条:每个播放器自管,绑定自身 playerCtr;买量蒙层时半透明,菜单显隐由组件内部跟随操作台
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _progressBar(logic),
),
],
),
),
),
);
},
);
}
/// 内嵌进度条。与短剧同一套写法:压暗态只有一档 40%,用**一个** AnimatedOpacity 表达——
/// 上下滑动切视频时(留个淡影不至于整条凭空消失)、挂着买量蒙层时都压到 40%,带 150ms 淡入淡出。
/// 无 isScrolling 信号则常显
Widget _progressBar(ShortVideoPlayerLogic logic) {
//二级页进度条贴底,避让系统手势条(home indicator),否则 thumb 和横线重合难点中;其他场景不加
final bottomSafe = widget.listType == VideoListType.SECOND
? Get.mediaQuery.padding.bottom + 10
: 0.0;
final Widget bar = VideoProgressWidget(
controller: logic.playerCtr,
padding: EdgeInsets.only(left: 12, right: 12, bottom: bottomSafe),
);
final scrolling = widget.isScrolling;
if (scrolling == null)
return Opacity(opacity: logic.showBuyMask ? _dimOpacity : 1, child: bar);
return ValueListenableBuilder<bool>(
valueListenable: scrolling,
//只做透明+屏蔽点击,不能用 Visibility——它会卸载 child,重建时 initState 会拿旧 controller 挂监听
builder: (_, isScroll, child) => IgnorePointer(
ignoring: isScroll,
child: AnimatedOpacity(
opacity: isScroll || logic.showBuyMask ? _dimOpacity : 1,
duration: _dimDuration,
child: child!,
),
),
child: bar,
);
}
/// 未就绪/缓冲中的居中转圈。直接监听 playerCtr(本身是 ValueNotifier),
/// 切视频/切线路换了控制器时 ValueListenableBuilder 会自动改订阅,无需手动 add/removeListener
Widget _bufferLoading(ShortVideoPlayerLogic logic) {
const indicator = Center(
child:
CupertinoActivityIndicator(color: AppColors.actionRed, radius: 15));
final ctr = logic.playerCtr;
if (ctr == null) return indicator; // 控制器还没建好,同样转圈
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: ctr,
builder: (_, value, __) => !value.isInitialized || value.isBuffering
? indicator
: const SizedBox(),
);
}
}
@@ -0,0 +1,180 @@
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/short_video/view/video_player_base_logic.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../../alert/video/buy_vip_alert.dart';
import '../../../alert/vip_guide/guide_bottom_sheet.dart';
import '../../../alert/vip_guide/guide_common.dart';
import '../../../alert/vip_guide/guide_config.dart';
import '../../../alert/vip_guide/guide_manager.dart';
import '../../../alert/vip_guide/timed_popup_manager.dart';
import '../../../hj_model/splash/watch_count_model.dart';
import '../../../hj_utils/api_service/vid_service.dart';
import '../../../hj_utils/free_play_manager.dart';
import '../../../tools_base/debug_log.dart';
import '../../pre_sale/pre_sale_provider.dart';
import '../../video/view/long_video_status.dart';
import '../short_video_page.dart';
/// 单条短视频的试看/付费逻辑。播放器本身的机制(建控制器、265 降级、可见性播停)在基类。
/// PageView 每项一个实例,由页面用唯一 tag 隔离
class ShortVideoPlayerLogic extends VideoPlayerBaseLogic {
ShortVideoPlayerLogic(
{required this.index, required this.videoInfo, this.listType});
/// index / videoInfo 会被页面同步刷新:列表可能用**同 id 的新对象**替换某条
/// (如首条拉完详情后 removeAt+insert)PageView 按 id 做 key 会复用 State
/// 这里不跟着换就会一直读旧 model —— 详情写进旧对象、权限判断也读旧数据
int index;
VideoModel? videoInfo;
final VideoListType? listType;
// ===== 付费/试看 =====
bool showBuyMask = false; // vip用遮罩, 金币用弹窗
String maskTitle = '试看结束';
bool _payAheadShown = false; // 本条「播满3秒前置提示」是否已弹(避免重复)
WatchCount? watchCount;
/// 当前该用的视频模型:SECOND 类型要用详情接口补齐权限后的那个对象
@override
VideoModel? get video {
if (listType == VideoListType.SECOND && videoInfo?.detailVModel != null) {
return videoInfo?.detailVModel;
}
return videoInfo;
}
@override
bool get isBlocked => showBuyMask;
@override
String get debugTag => 'page $index';
LongVideoStatus get videoStatus => longVideoStatus(video);
@override
void onInit() {
super.onInit();
initPlayer(video);
_useFreeCount();
if (listType == VideoListType.SECOND && videoInfo?.detailVModel == null) {
// 从列表页进入短视频,需要获取视频详情数据,来处理权限问题;(列表数据服务端为了速度,没有获取相关权限数据,前端单独获取)
_loadDetail(canRetry: true);
}
}
/// 竖滑回到本条:进度归零重看(切 tab 回来要接着播,所以不能归零)。
/// 蒙层在这里被清掉,必须重新求值播放,否则「可见性没再变化」时没人负责起播
void onBecomeCurrent() {
showBuyMask = false;
resetCompleted();
playerCtr?.seekTo(Duration.zero);
syncPlay();
}
Future<void> _loadDetail({bool canRetry = false}) async {
// 先锁定本次的 modelawait 期间 videoInfo 可能被页面换成同 id 的新对象,
// 那样就会「锁 A、解锁 B」,A 的 isLoadingDetail 永久残留
final target = videoInfo;
if (target?.isLoadingDetail == true) return; // 同一 model 正在拉,别并发
target?.isLoadingDetail = true;
try {
final detail = await VidService.getDetail(target?.id ?? '');
if (detail != null) target?.detailVModel = detail;
} catch (e) {
debugLog(e.toString());
// 必须先解锁再重试:锁挂在 model 上、跟着 model 活,
// 不解锁重试会被自己的守卫挡掉,且这条视频后续再也拉不到详情
target?.isLoadingDetail = false;
if (canRetry) return _loadDetail(canRetry: false);
}
target?.isLoadingDetail = false;
}
/// 消耗本条的免费观看/金币权益次数,并快照剩余次数给菜单展示
void _useFreeCount() {
FreePlayManager().useFreePlay(video);
watchCount = FreePlayManager().remain?.copy();
presaleProvider.useCoinPrivilege(video);
watchCount?.coinWatchCount = presaleProvider.coinVideoFreeCount;
}
/// 每帧回调:试看到点拦截
@override
void onTick(int position) {
if (video?.freeArea == true) return;
if (FreePlayManager().useFreePlay(video)) return;
// 历史记录已统一收到 ShortVideoLogic.onPageChanged:滑到即记,与播放器/权限/进度无关
// vip用遮罩, 金币用弹窗;有金币免费观看次数时不再要求购买
final status = videoStatus;
final isNeedBuy =
status.isNeedBuy && (watchCount?.coinWatchCount ?? -1) < 0;
if (!isNeedBuy && !status.isNeedVip) return;
_showPayAhead(); // 场景1(短)under7/over7 播满3秒的前置提示
if (position < (video?.freeTime ?? 10)) return;
if (playerCtr?.value.isPlaying != true) return;
playerCtr?.pause();
if (listType == VideoListType.SECOND && videoInfo?.detailVModel == null) {
showToast("权限校验失败");
_loadDetail();
return;
}
maskTitle = '试看结束';
showBuyMask = true;
notify();
// 与长视频对齐:VIP 片在蒙层之上叠开通弹窗;金币片只留蒙层,由蒙层引导金币解锁/观影券
// 不走 TimedPopupManager:这是硬拦截,被互斥挡下就等于没弹
if (status.isNeedVip && !isClosed) BuyVipAlert.show(videoId: video?.id);
}
/// 起播引导(VIDEO_PREVIEW_END):需付费的短视频播满 3 秒前置弹一次购买引导(暂停,关掉后续播,每条一次)。
/// 走播放进度而非墙钟计时:滑走即 pause、position 停住,天然不会在非当前页误触发。
Future<void> _showPayAhead() async {
if (_payAheadShown) return;
if (playerCtr?.value.isPlaying != true) return;
if ((video?.freeTime ?? 10) < 3) return; // 不足3秒:没等到提示就已被试看结束拦截了
if ((playerCtr?.value.position.inSeconds ?? 0) < 3) return;
if (!_canShowGuide) return;
_payAheadShown = true; // await 前置位,防 listener 每帧重入
// 先备好卡数据再暂停,否则拉数据期间视频白停几百毫秒;场景没配卡就压根不弹,别白暂停一下
if (await loadGuideCard(GuideScene.videoPreviewEnd) == null) return;
if (isClosed || !isVisible) return; // 拉数据期间滑走/切走了就别弹了
// freeTime 恰好是3秒时,本引导和试看结束会在同一帧触发,蒙层挂着就别再叠一个弹窗
// (试看结束改走 BuyVipAlert 后不再经 TimedPopupManager,互斥挡不住这种情况)
if (showBuyMask) return;
playerCtr?.pause();
bool goVip = false;
await TimedPopupManager().trigger(
canShow: () => _canShowGuide,
//短视频按钮无价文案;长视频才用默认的「立即开通 · 仅需¥价格」
onShow: () async =>
goVip = await GuideBottomSheet.showVideo(buttonText: '开通会员 · 查看完整内容'),
);
// 跳了会员页就不恢复播放;滑到别条 / 期间被试看结束拦下弹了蒙层,由 syncPlay 兜住
if (goVip || isClosed) return;
syncPlay();
}
/// 起播前置引导开关是否允许弹(后端下发的 enabled && show)。
/// 试看结束不再走这个开关——那里是 BuyVipAlert 硬拦截,与长视频一致
bool get _canShowGuide => GuideManager().canShow(GuideScene.videoPreviewEnd);
/// 菜单点购买:暂停并弹解锁蒙层
void onBuy() {
final status = videoStatus;
if (!status.isNeedPay) return;
playerCtr?.pause();
maskTitle = '解锁完整视频';
showBuyMask = true;
notify();
}
/// 蒙层内购买成功:撤蒙层并按可见性决定要不要续播
void onBuySuccess() {
showBuyMask = false;
video?.vidStatus?.hasPaid = true;
// 可能是在充值页付完才回调,页面还没露出来就别出声,等切回来由可见性接着播
syncPlay();
notify();
}
}
@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_page/short_video/view/video_player_base_logic.dart';
import 'package:hgdj/hj_page/video/video_full_page.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:video_player/video_player.dart';
typedef MenuSwitchCallback = void Function(bool); // true 显示
/// 操作台(左右菜单 + 进度条)的显隐开关,全局一份:
/// 短视频和短剧各有各的操作台,但「把界面收起来」是同一个用户意图,状态必须共享
class VideoMenuSwitch {
VideoMenuSwitch._();
static bool isShow = true;
static final List<MenuSwitchCallback> _listeners = [];
static void addListen(MenuSwitchCallback callback) =>
_listeners.add(callback);
static void removeLister(MenuSwitchCallback callback) =>
_listeners.remove(callback);
static void toggle() {
isShow = !isShow;
notifyListeners();
}
//遍历副本,避免回调中增删监听导致并发修改
static void notifyListeners() {
for (final callback in List.of(_listeners)) {
callback(isShow);
}
}
}
/// 操作台右侧的动作项:图标 + 文案,图标切换(点赞/收藏选中态)带缩放淡入
Widget menuActionItem(String title, String imgPath,
{GestureTapCallback? onTap}) {
return InkWell(
enableFeedback: false,
onTap: onTap,
child: Column(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: FadeTransition(opacity: animation, child: child),
),
child: Image.asset(
imgPath,
key: ValueKey(imgPath),
width: 30,
height: 30,
),
),
//空文案(眼睛按钮)不占位,否则底下多一行行高、和左侧信息栏对不齐;
//FittedBox 让线路名之类的长文案缩成一行,别撑成两行把菜单项间距顶乱
if (title.isNotEmpty)
FittedBox(
child: Text(title,
style: const TextStyle(
color: Color(0xffDCDCDC),
fontSize: 11,
fontWeight: FontWeight.w500)),
),
],
),
);
}
/// 操作台显隐过渡:淡入淡出 + 横向滑动。
/// 隐藏时保留占位(避免布局跳动)且 IgnorePointer 不拦截点击(让点击穿透到视频)。
class MenuReveal extends StatelessWidget {
final bool visible;
final Offset offset; // 隐藏时的位移(相对自身尺寸):右侧菜单 (0.35,0) 右滑、左侧信息栏 (-0.1,0) 左滑
final Widget child;
const MenuReveal({
super.key,
required this.visible,
required this.child,
this.offset = const Offset(0, 0.1),
});
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 250);
return IgnorePointer(
ignoring: !visible,
child: AnimatedSlide(
duration: duration,
curve: Curves.easeOut,
offset: visible ? Offset.zero : offset,
child: AnimatedOpacity(
duration: duration,
curve: Curves.easeOut,
opacity: visible ? 1 : 0,
child: child,
),
),
);
}
}
/// 中间播放/暂停指示:暂停时显示三角、播放时淡出,切换做形变过渡。
/// 不拦截点击,交给底层视频手势,避免双重触发
class CenterPlayPause extends StatefulWidget {
final VideoPlayerController controller;
const CenterPlayPause({super.key, required this.controller});
@override
State<CenterPlayPause> createState() => CenterPlayPauseState();
}
class CenterPlayPauseState extends State<CenterPlayPause>
with SingleTickerProviderStateMixin {
// play_pause 形变进度:0=播放三角,1=暂停双杠
late final AnimationController _morphCtr = AnimationController(
duration: const Duration(milliseconds: 260),
vsync: this,
);
bool _isPlaying = false;
bool _isInited = false;
@override
void initState() {
super.initState();
_sync(initial: true);
widget.controller.addListener(_onValue);
}
void _onValue() {
final v = widget.controller.value;
// 仅在播放态/初始化态真正变化时刷新,避免每帧 position 回调触发 setState
if (v.isPlaying != _isPlaying || v.isInitialized != _isInited) {
_sync();
}
}
void _sync({bool initial = false}) {
final v = widget.controller.value;
_isPlaying = v.isPlaying;
_isInited = v.isInitialized;
if (initial) {
_morphCtr.value = _isPlaying ? 1 : 0; // 初始无动画直接定位
} else {
_isPlaying ? _morphCtr.forward() : _morphCtr.reverse(); // 切换走形变
}
if (mounted) setState(() {});
}
@override
void didUpdateWidget(covariant CenterPlayPause oldWidget) {
super.didUpdateWidget(oldWidget);
// 切线路/复用:controller 变了,迁移监听并按新状态重定位
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onValue);
widget.controller.addListener(_onValue);
_sync(initial: true);
}
}
@override
void dispose() {
widget.controller.removeListener(_onValue);
_morphCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final bool show = _isInited && !_isPlaying; // 暂停时显示
return IgnorePointer(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOut,
opacity: show ? 1 : 0,
child: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _morphCtr,
size: 56,
color: Colors.white.withValues(alpha: 0.95),
),
),
);
}
}
/// 横向视频(direction=横 / 宽高比>1)才露出的转全屏按钮,短视频和短剧共用。
/// 按钮位置跟着宽高比走:越宽的视频画面越矮,按钮要跟着往上收
class FullScreenButton extends StatelessWidget {
final VideoPlayerBaseLogic logic;
const FullScreenButton({super.key, required this.logic});
@override
Widget build(BuildContext context) {
final ratio = (logic.playerCtr?.value.aspectRatio ?? 1) - 1;
return Visibility(
visible: logic.isWide,
child: Align(
alignment: Alignment(0.0, ratio / 4 + 0.05),
child: Padding(
padding: const EdgeInsets.only(top: 100),
child: InkWell(
enableFeedback: false,
onTap: () async {
// 全屏页复用同一个 playerCtr,本页被盖住报不可见时不能跟着暂停
logic.isFullScreen = true;
await Get.to(() => VideoFullPage(playCtr: logic.playerCtr!));
logic.isFullScreen = false;
},
//胶囊:半透明黑底 + 40% 白描边,14px 图标 + 「全屏观看」
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.1),
border: Border.all(color: Colors.white.withValues(alpha: 0.4)),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('full_screen.webp'.videoPath,
width: 14, height: 14),
4.sizeBoxW,
const Text('全屏观看',
style: TextStyle(color: Colors.white, fontSize: 12)),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,255 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/codec_support.dart';
import 'package:hgdj/hj_utils/play_diagnose.dart';
import 'package:hgdj/hj_utils/video_view_type.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:video_player/video_player.dart';
import 'package:visibility_detector/visibility_detector.dart';
import '../../../tools_base/debug_log.dart';
/// 竖屏播放器的**播放器管线**:建控制器、H.265 降级、可见性驱动播停、倍速、完播检测。
/// 只放与业务无关的机制——试看拦截、付费墙、续播这些各自在子类里做。
/// 短视频([ShortVideoPlayerLogic])和短剧([DramaVideoPlayerLogic])共用它,但互不知道对方存在
abstract class VideoPlayerBaseLogic extends GetxController {
// ===== 全局暂停广播 =====
// 播放由各播放器按自身可见性自理,这里只广播「全都停下」
// (拔耳机/来电/跳外部页等与可见性无关的场景)
static final List<VoidCallback> _pauseListeners = [];
static void pauseAll() {
for (final callback in [..._pauseListeners]) {
callback();
}
}
// ===== 播放器 =====
VideoPlayerController? playerCtr;
bool isWide = false; // 横屏视频(宽高比>1.3),露出转全屏按钮
String? _playUrl; // 当前播放 url
bool _forceH264 = false; // 本条是否已回退 264:初始化失败/播放中报错都置位(仅内存;新条=新 logic 自动复位)
/// 当前是否在播 H.265 源。**必须选流当下记录**,不能用 `_playUrl == realH265Url` 派生——
/// token/cdn 刷新、详情异步换对象,都会让派生结果误判成 false,265 降级链就失效了
bool _is265 = false;
// ===== 播放开关 =====
/// 本条是否真的显示在屏幕上——播/停的唯一依据。底部切 tab、竖滑到别条、
/// 被别的路由盖住,都会让它变 false,不用再各处广播播放状态
bool _isVisible = false;
bool get isVisible => _isVisible;
/// 全屏页复用同一个 playerCtr,进全屏时本页被路由盖住会报不可见,此时不能跟着暂停
bool isFullScreen = false;
/// 同一条视频可能同时挂在多个 tab 上,用实例级 key 保证全局唯一
final visibilityKey = UniqueKey();
bool _completedSent = false; // 本次播放是否已回调过完播
// ===== 子类要给的 =====
/// 当前该播的模型
VideoModel? get video;
/// 有遮罩挂着不该播(试看结束 / 付费墙)
bool get isBlocked;
/// 播放进度回调,已过滤掉销毁和 265 报错。[position] 当前秒数。
/// **不是每帧**video_player 只在播放中挂一个 500ms 的 Timer 拉 position(暂停即 cancel)
/// 稳态就是 2 次/秒;另外 value 有变化(缓冲/播停/尺寸)时会顺带多触发几次
void onTick(int position);
/// 本条播完,每次播放只回一次
void onFinished() {}
/// 日志用的标识,子类给个下标就行
String get debugTag;
@override
void onInit() {
super.onInit();
_pauseListeners.add(onPauseAll);
}
@override
void onClose() {
_pauseListeners.remove(onPauseAll);
// 库只在「变不可见」时清缓存,仍可见就被销毁(整页退出)的那几条会残留,这里兜底
VisibilityDetectorController.instance.forget(visibilityKey);
playerCtr?.removeListener(_onProgress);
playerCtr?.dispose();
//必须置空:销毁后 widget 树可能还会重建一帧(PageView 卸载/Visibility 复位)
//拿到已 dispose 的控制器再 addListener 就是 "used after being disposed"
playerCtr = null;
debugLog("dispose--> $debugTag");
super.onClose();
}
/// 全局静音场景(拔耳机/来电/跳外部页):只负责停,恢复播放仍由可见性决定。
/// 子类要在停之前做事(如落盘进度)就覆写它,记得调 super
@protected
void onPauseAll() => playerCtr?.pause();
/// 可见性变化:更新状态后重新求值播放
void onVisibility(VisibilityInfo info) {
// 阈值取 0.5:竖滑时前后两条占比之和为 1,只可能有一条过半,天然不会两条同时播;
// 也不怕机型安全区/布局把播放器裁掉一点(用 0.9 会因此永远播不起来)
final visible = info.visibleFraction > 0.5;
if (_isVisible == visible) return;
_isVisible = visible;
syncPlay();
}
/// 播/停的唯一出口:可见且没挂遮罩才播,不可见就停。
/// 凡是改了可见性 / [isBlocked] 的地方都要调一次——只在可见性回调里判,
/// 遮罩被别处清掉(如滑回本条)时就没人负责重新起播了
void syncPlay() {
// 销毁后仍会被调到:VisibilityDetector 卸载时补一次 0 回调、initialize/seekTo 的 await 也可能跨过销毁,
// 那时 playerCtr 已在 onClose() 里释放,再 play/pause 就是用已释放的控制器
if (isClosed) return;
if (isFullScreen) return; // 全屏中,播放归全屏页管
if (!_isVisible) {
playerCtr?.pause();
debugLog("page====pause:$debugTag");
return;
}
if (isBlocked) return;
playerCtr?.play();
debugLog("page====play:$debugTag");
}
/// 异步回调里刷新 UI,统一挡一道销毁。
/// 不能叫 refresh()——GetX 的 update() 无参时内部就是调 refresh(),覆写它会自递归成 Stack Overflow
void notify() {
if (!isClosed) update();
}
/// 滑回本条重看:完播回调重新可触发
@protected
void resetCompleted() => _completedSent = false;
/// CDN 路线切换:同视频换 URL 重新起播
void onSwitchLine() {
initPlayer(video);
notify();
}
/// [model] 显式传入而不读 getter:失败重试链要一路用同一个 model
Future<void> initPlayer(VideoModel? model, {bool isRetry = false}) async {
if (isClosed) return; // 已销毁(快速滑走/失败重试排队中),别再建控制器,否则没人释放
VideoPlayerController? preVideoCtr =
playerCtr; // 旧控制器,切线路/失败后需释放(声明在 try 外,catch 才能拿到)
try {
preVideoCtr?.pause();
int prePosition = preVideoCtr?.value.position.inSeconds ?? 0;
// 只用主源:设备硬解支持 265、未回退、有 265 地址 → 265,否则 264(realVideoUrl)
final h265 = model?.realH265Url ?? '';
final use265 = CodecSupport.useH265 && !_forceH264 && h265.isNotEmpty;
_playUrl = use265 ? h265 : model?.realVideoUrl;
_is265 = use265; // 选流结果直接记录,后续 token/cdn 变化不影响判定
debugLog("竖屏播放 url(${use265 ? 'H265' : 'H264'})$_playUrl");
playerCtr = PlayerFactory.network(_playUrl); // 仅网络播放,不走本地文件
notify();
await playerCtr?.initialize();
if (isRetry)
confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地
preVideoCtr?.removeListener(_onProgress);
preVideoCtr?.dispose(); // 切线路/刷新:释放旧控制器,避免解码器泄漏累积导致后续视频全部加载失败
// initialize 期间可能已被销毁(快速连滑)playerCtr 已在 onClose() 里释放,
// 再往上挂监听/起播就是在用已释放的控制器
if (isClosed) return;
playerCtr?.addListener(_onProgress);
isWide = (playerCtr?.value.aspectRatio ?? 1) > 1.3;
// 初始化是异步的,期间可能已经滑走/切走,只在仍显示在屏幕上时才起播
if (_isVisible) {
if (prePosition > 1) {
await playerCtr?.seekTo(Duration(seconds: prePosition));
}
syncPlay();
}
} catch (e) {
preVideoCtr?.dispose(); // 释放进入本次时的旧控制器
debugLog("视频初始化失败:$e play url = $_playUrl");
if (isDecoderError(e)) {
// 1) 先按芯片兼容机制切 platformView 重试(保留硬解/265,海思等渲染错常能救回)
if (switchToPlatformView()) {
initPlayer(model, isRetry: true);
return;
}
// 2) platformView 也救不了且当前放的是 265:本机永久禁用 265 并落本地,用 264 重试
if (_is265 && CodecSupport.disableForDevice()) {
initPlayer(model, isRetry: true);
return;
}
}
// 3) 非解码类错误(404/域名不通/非法 m3u8):只是这条 265 资源坏,与芯片无关 → 仅本条回退 264。
// isRetry 透传而非写死 true:没切过 platformView 就别让 264 播通了去误标它
if (_is265 && !_forceH264) {
_forceH264 = true;
debugLog("H.265 资源不可用,回退 264 兜底重试");
initPlayer(model, isRetry: isRetry);
return;
}
PlayDiagnose.report(
scene: '竖屏播放失败($debugTag)',
error: e,
url: _playUrl,
videoId: model?.id,
videoTitle: model?.title,
isH265: _is265,
forceH264: _forceH264,
);
if (!isClosed) showToast("视频资源加载失败"); // 已滑走就别再弹了
}
notify();
}
/// 265 运行时播放器报错:本条回退 264 重播,不持久化(下条/重启仍试 265)。
/// 重播后 _playUrl 变 264 → _is265 为 false`_forceH264` 守卫防重建期间重入
void _fallbackTo264() {
if (isClosed || !_is265 || _forceH264) return;
_forceH264 = true;
debugLog("H.265 运行时报错,回退 264 重播");
initPlayer(video);
}
/// 播放器进度回调(约 2 次/秒,见 onTick 注释):265 报错回退 → 完播回调 → 交给子类
void _onProgress() {
if (isClosed) return;
final ctr = playerCtr;
if (ctr == null) return;
if (_is265 && ctr.value.hasError) {
_fallbackTo264();
return;
}
final position = ctr.value.position.inSeconds;
if (position >= ctr.value.duration.inSeconds - 1) {
// duration 为 0 的异常流会让上面的条件恒成立,这里挡一道,别刚起播就当播完
if (!_completedSent && ctr.value.duration.inSeconds > 0) {
_completedSent = true;
onFinished();
}
}
onTick(position);
}
/// 点屏播放/暂停。返回操作前是否在播(子类要据此落进度)
bool togglePlay() {
final isPlaying = playerCtr?.value.isPlaying == true;
isPlaying ? playerCtr?.pause() : playerCtr?.play();
return isPlaying;
}
/// 长按 2 倍速
void speedUp() {
showToast("长按不动,2X倍速播放");
playerCtr?.setPlaybackSpeed(2.0);
}
/// 长按结束/抬手都要恢复常速
void resetSpeed() => playerCtr?.setPlaybackSpeed(1.0);
}
@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:video_player/video_player.dart';
const double _kSliderThumbRadius = 6; //滑杆圆点外圈半径(按设计稿外圈直径12;轨道两端按此内缩,避免压到时间文字)
//视频进度条:当前时间 + 原生 Slider 滑杆 + 总时长(拖动/点击由框架处理,手感更跟手)
class VideoProgressWidget extends StatefulWidget {
final EdgeInsets? padding;
final VideoPlayerController? controller;
final Function(bool)? gestureCallback; // true 开始, false 结束
final Function(Duration?)? skipCallback;
/// 外部拖动预览位置(如播放器整屏横滑时传入):非空时进度条 thumb/时间据它显示
final Duration? previewSeek;
/// 试看场景下松手回弹边界:超出 [minSeekDuration, maxSeekDuration] 时回到边界
final Duration? minSeekDuration;
final Duration? maxSeekDuration;
/// 是否允许拖动进度(试看结束锁定时传 false,禁止拖动)
final bool enableSeek;
const VideoProgressWidget({
super.key,
this.padding,
this.controller,
this.gestureCallback,
this.skipCallback,
this.previewSeek,
this.minSeekDuration,
this.maxSeekDuration,
this.enableSeek = true,
});
@override
State<VideoProgressWidget> createState() => _VideoProgressWidgetState();
}
class _VideoProgressWidgetState extends State<VideoProgressWidget> {
VideoPlayerController? playerCtr;
Duration? _seekPos; //本组件滑杆拖动中的目标进度(非空时进度条按它显示,松手后清空)
bool _isShowMenu = true; //操作台是否显示(跟随操作台显隐)
String? _timeKey; //上次实测对应的总时长格式串
double _timeWidth = 0; //缓存的时间文本宽度
//显示用进度:本组件拖动优先,其次外部横滑预览
Duration? get _displaySeek => _seekPos ?? widget.previewSeek;
//当前进度比例 0~1
double get _progress {
final totalMs = playerCtr?.value.duration.inMilliseconds ?? 0;
if (totalMs <= 0) return 0;
final curMs = (_displaySeek ?? playerCtr!.value.position).inMilliseconds;
return curMs < 0 ? 0 : curMs / totalMs;
}
@override
void initState() {
super.initState();
VideoMenuSwitch.addListen(_onMenuSwitch);
playerCtr = widget.controller;
playerCtr?.addListener(_onTick);
}
@override
void didUpdateWidget(covariant VideoProgressWidget oldWidget) {
super.didUpdateWidget(oldWidget);
//构造参数传入的 controller 变化时迁移监听(短视频切线路/列表项复用、长视频换集都走这里)
if (widget.controller != null && widget.controller != playerCtr) {
playerCtr?.removeListener(_onTick);
playerCtr = widget.controller;
playerCtr?.addListener(_onTick);
}
}
@override
void dispose() {
playerCtr?.removeListener(_onTick);
VideoMenuSwitch.removeLister(_onMenuSwitch);
super.dispose();
}
//操作台显隐回调:进度条跟随操作台一起显示/隐藏
void _onMenuSwitch(bool isShow) {
_isShowMenu = isShow;
_rebuild();
}
void _onTick() => _rebuild();
//controller 监听可能在 build/layout 阶段触发,此时直接 setState 会抛 "setState during build"
//非空闲帧先等当前帧结束再刷新
Future<void> _rebuild() async {
if (!mounted) return;
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.idle) {
await SchedulerBinding.instance.endOfFrame;
if (!mounted) return;
}
setState(() {});
}
//复用 DateTimeUtil 的 MM:SS / HH:MM:SS 格式化;
//forceHour: 总时长有小时位时,已播放时间不足 1 小时也补 "00:" 对齐格式(如 00:00:01
String _formatTime(int seconds, {bool forceHour = false}) {
final text = DateTimeUtil.formatDuration(Duration(seconds: seconds)) ?? "";
return (forceHour && seconds < 3600) ? "00:$text" : text;
}
//限制在 [0, 总时长] 内
Duration _clampSeek(Duration duration) {
var ms = duration.inMilliseconds;
if (ms < 0) ms = 0;
final totalMs = playerCtr?.value.duration.inMilliseconds ?? 0;
if (totalMs > 0 && ms > totalMs) ms = totalMs;
return Duration(milliseconds: ms);
}
/// 松手时:超出试看区间则回弹到区间边界(超出末尾 → 试看结束点)
Duration _snapSeekOnRelease(Duration duration) {
final result = _clampSeek(duration);
final minMs = widget.minSeekDuration?.inMilliseconds;
final maxMs = widget.maxSeekDuration?.inMilliseconds;
if (minMs != null && result.inMilliseconds < minMs)
return widget.minSeekDuration!;
if (maxMs != null && result.inMilliseconds > maxMs)
return widget.maxSeekDuration!;
return result;
}
//拖动开始:暂停由外部 gestureCallback 处理
void _onSeekStart(double _) {
if (playerCtr?.value.isInitialized != true) return;
widget.gestureCallback?.call(true);
}
//拖动中:更新预览进度 + 回调外部(中间时间提示)
void _onSeekUpdate(double v) {
final dur = playerCtr?.value.duration;
if (dur == null) return;
_seekPos = _clampSeek(dur * v);
widget.skipCallback?.call(_seekPos);
setState(() {});
}
//松手:回弹边界 → seekTo → 延迟清预览 → 通知结束
Future<void> _onSeekEnd(double v) async {
if (playerCtr?.value.isInitialized != true) return;
final target = _snapSeekOnRelease(playerCtr!.value.duration * v);
_seekPos = target;
try {
await playerCtr?.seekTo(target);
} catch (e) {
debugLog("seekTo:$e");
}
if (!mounted) return; //await 期间可能已退出/销毁,避免回调到已 dispose 的父组件
Future.delayed(const Duration(milliseconds: 500), () {
_seekPos = null;
});
widget.gestureCallback?.call(false);
}
@override
Widget build(BuildContext context) {
//操作台隐藏时:淡出 + 向下滑出,并 IgnorePointer 禁止拖动;无 controller 时透明占位(保留布局,避免初始化前后跳动)
const duration = Duration(milliseconds: 250);
return IgnorePointer(
ignoring: !_isShowMenu,
child: AnimatedSlide(
duration: duration,
curve: Curves.easeOut,
offset: _isShowMenu ? Offset.zero : const Offset(0, 0.6),
child: AnimatedOpacity(
duration: duration,
curve: Curves.easeOut,
opacity: _isShowMenu ? 1 : 0,
child: Opacity(
opacity: playerCtr == null ? 0 : 1,
child: Padding(
padding: widget.padding ?? EdgeInsets.zero,
child: _buildProgress(),
),
),
),
),
);
}
Widget _buildProgress() {
final totalSeconds = playerCtr?.value.duration.inSeconds ?? 0;
final forceHour = totalSeconds >= 3600; //总时长超 1 小时,已播放时间也统一带小时位
final totalText = _formatTime(totalSeconds);
//宽度只随总时长格式串变化(视频时长基本恒定),缓存避免每帧 new TextPainter
if (totalText != _timeKey) {
_timeKey = totalText;
_timeWidth = _measureWidth(totalText);
}
final curSeconds =
_displaySeek?.inSeconds ?? playerCtr?.value.position.inSeconds ?? 0;
return Row(
children: [
_timeText(curSeconds,
alignment: Alignment.centerRight, forceHour: forceHour),
3.sizeBoxW,
Expanded(child: _buildSlider()),
3.sizeBoxW,
_timeText(totalSeconds,
alignment: Alignment.centerLeft, forceHour: forceHour),
],
);
}
//实测时间文本宽度(按总时长格式串),带上系统字体缩放避免折行,向上取整 +1 防边界裁剪
//style 要和 _timeText 里渲染用的一致,否则量出来的宽度对不上
double _measureWidth(String text) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()]),
),
textDirection: TextDirection.ltr,
textScaler: MediaQuery.textScalerOf(context),
)..layout();
return painter.width.ceilToDouble() + 1;
}
//左侧时间右对齐、右侧时间左对齐,整体向滑杆靠拢。
//tabularFigures 必须要:宽度是拿总时长那串量的,两边共用;iOS 系统字体数字是比例宽度
//(0 比 1 宽不少)00:05 会比 16:31 宽而撑出 SizedBox,被最左端的圆点压住。
//等宽数字后所有时间串同宽,顺带消掉秒数跳动时的左右抖动
Widget _timeText(int seconds,
{required Alignment alignment, bool forceHour = false}) =>
SizedBox(
width: _timeWidth,
child: Align(
alignment: alignment,
child: Text(
_formatTime(seconds, forceHour: forceHour),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()]),
maxLines: 1,
softWrap: false,
),
),
);
Widget _buildSlider() {
return SizedBox(
height: 28,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2,
overlayShape: SliderComponentShape.noOverlay,
trackShape: _VideoTrackShape(),
thumbShape: const _VideoThumbShape(),
),
//onChanged 为 null 时 Slider 自动禁用(试看锁定)
child: Slider(
min: 0,
max: 1,
value: _progress.clamp(0.0, 1.0),
onChangeStart: widget.enableSeek ? _onSeekStart : null,
onChanged: widget.enableSeek ? _onSeekUpdate : null,
onChangeEnd: widget.enableSeek ? _onSeekEnd : null,
),
),
);
}
}
//进度轨道:左侧已播放=白色,右侧未播放=半透明白(视频上更清晰)
class _VideoTrackShape extends SliderTrackShape {
static final _active = Paint()..color = Colors.white;
static final _inactive = Paint()..color = Colors.white.withValues(alpha: 0.3);
@override
Rect getPreferredRect({
bool isDiscrete = false,
bool isEnabled = false,
Offset offset = Offset.zero,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
}) {
final h = sliderTheme.trackHeight ?? 2;
final top = offset.dy + (parentBox.size.height - h) / 2;
//左右各内缩一个圆点半径,thumb 到达两端时不越界压到时间文字
final w = parentBox.size.width - _kSliderThumbRadius * 2;
return Rect.fromLTWH(
offset.dx + _kSliderThumbRadius, top, w > 0 ? w : 0, h);
}
@override
void paint(
PaintingContext context,
Offset offset, {
required Animation<double> enableAnimation,
bool isDiscrete = false,
bool isEnabled = false,
required RenderBox parentBox,
Offset? secondaryOffset,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required Offset thumbCenter,
}) {
final rect = getPreferredRect(
parentBox: parentBox, sliderTheme: sliderTheme, offset: offset);
final canvas = context.canvas;
//整条轨道裁成圆角(高度一半=胶囊),内部两段平涂:外侧两端圆角、thumb 处接缝平齐
canvas.save();
canvas.clipRRect(
RRect.fromRectAndRadius(rect, Radius.circular(rect.height / 2)));
canvas.drawRect(
Rect.fromLTRB(rect.left, rect.top, thumbCenter.dx, rect.bottom),
_active);
canvas.drawRect(
Rect.fromLTRB(thumbCenter.dx, rect.top, rect.right, rect.bottom),
_inactive);
canvas.restore();
}
}
//圆形 thumb:外圈白@50%(视频上显灰)、内圈白@100%(按设计稿外圈12/内圈10)
class _VideoThumbShape extends SliderComponentShape {
static final _outer = Paint()..color = Colors.white.withValues(alpha: 0.5);
static final _inner = Paint()..color = Colors.white;
const _VideoThumbShape();
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
Size.fromRadius(_kSliderThumbRadius);
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter labelPainter,
required RenderBox parentBox,
required Size sizeWithOverflow,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double textScaleFactor,
required double value,
}) {
final canvas = context.canvas;
canvas.drawCircle(center, _kSliderThumbRadius, _outer); //外圈 r6 白@50%
canvas.drawCircle(center, _kSliderThumbRadius - 1.5, _inner); //内圈 r5 白
}
}