初始化
This commit is contained in:
@@ -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 {
|
||||
// 先锁定本次的 model:await 期间 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 白
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user