初始化

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,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../hj_model/acg/comic_chapters_model.dart';
import '../../../hj_model/cartoon_media_info.dart';
import '../../../hj_utils/api_service/acg_service.dart';
import '../../cartoon/widget/free_badge.dart';
import '../../live/live_widget.dart';
class CartoonEpisodeMenuAlert extends StatefulWidget {
final ComicChapterInfo? curModel;
final CartoonMediaInfo? cartoonModel;
final Function(ComicChapterInfo)? callback;
CartoonEpisodeMenuAlert(this.curModel,
{super.key, this.callback, this.cartoonModel});
@override
State<StatefulWidget> createState() {
return _CartoonEpisodeMenuAlertState();
}
}
class _CartoonEpisodeMenuAlertState extends State<CartoonEpisodeMenuAlert> {
CartoonMediaInfo? get cartoonModel => widget.cartoonModel;
RefreshController? refreshCtr;
bool _isLoading = false;
void _loadMoreData() async {
if (_isLoading) return;
_isLoading = true;
try {
final currentPage = cartoonModel?.episodeCurPage ?? 0;
final episodeModel = await ACGService.getChapterList(
cartoonModel?.id ?? "",
currentPage + 1,
cartoonModel?.episodePageSize ?? 40,
sortType: cartoonModel?.isUpSort,
);
if ((currentPage + 1) == 1) {
cartoonModel?.episodeList = episodeModel?.list;
} else {
cartoonModel?.episodeList?.addAll(episodeModel?.list ?? []);
}
cartoonModel?.episodeCurPage = currentPage + 1;
cartoonModel?.haxNextEpisode = episodeModel?.hasNext ?? true;
} catch (e) {
debugLog(e);
}
_isLoading = false;
//异步返回时 sheet 可能已关闭,避免对已卸载组件 setState / 操作已释放的 refreshCtr
if (!mounted) return;
setState(() {});
cartoonModel?.haxNextEpisode == true
? refreshCtr?.loadComplete()
: refreshCtr?.loadNoData();
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Container(
padding: EdgeInsets.fromLTRB(18.w, 21, 18.w, 32),
height: 460,
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
color: Color(0xff0F0F0F)),
child: Column(
children: [
const SheetHandleBar(color: Color(0x4DFFFFFF)),
18.h.sizeBoxH,
Row(
children: [
Text(
"剧集列表",
style: TextStyle(
fontSize: 20.sp,
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
],
),
18.h.sizeBoxH,
Expanded(
child: pullYsRefresh(
onInit: (ctr) => refreshCtr = ctr,
enablePullDown: false,
onLoading: (refreshCtr) => _loadMoreData(),
noDataText: "",
child: GridView.builder(
padding: EdgeInsets.zero,
itemCount: cartoonModel?.episodeList?.length ?? 0,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
crossAxisSpacing: 8,
mainAxisSpacing: 12,
childAspectRatio: 1,
),
itemBuilder: (context, index) {
ComicChapterInfo model = cartoonModel!.episodeList![index];
bool isSelected = model.id == widget.curModel?.id;
return GestureDetector(
onTap: () {
if (!isSelected) {
widget.callback?.call(model);
Get.back();
}
},
child: CartoonChapterItem(
model,
isSelected: isSelected,
fatherM: cartoonModel!,
),
);
},
),
),
),
],
),
),
);
}
}
class CartoonChapterItem extends StatelessWidget {
final ComicChapterInfo model;
final CartoonMediaInfo fatherM;
final bool isSelected;
final bool canPlay;
const CartoonChapterItem(this.model,
{super.key,
this.isSelected = false,
this.canPlay = true,
required this.fatherM});
// 前 N 集免费(当前集号 <= freeEpisode)且整本无任何权限时,展示「免费」角标
bool get _isFree =>
fatherM.hasPermission == false &&
fatherM.isFreeEpisodeNumber(model.episodeNumber);
@override
Widget build(BuildContext context) {
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: isSelected ? Color(0x1A6975FF) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? AppColors.actionRed : Color(0x1AFFFFFF),
width: 1),
),
child: Stack(
alignment: Alignment.center,
children: [
//前 N 集免费且整本无权限 → 左上角「免费」角标
if (_isFree)
const Positioned(top: 0, left: 0, child: FreeBadge(isCorner: true)),
Container(
padding: EdgeInsets.only(top: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${model.episodeNumber}",
style: TextStyle(
color: isSelected ? AppColors.actionRed : Color(0xE5FFFFFF),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (isSelected && canPlay) ...[
2.sizeBoxW,
AudioWaveView(
color: AppColors.actionRed,
height: 10,
),
],
],
),
),
],
),
);
}
}
@@ -0,0 +1,374 @@
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/home/tag/cartoon_tag_page.dart';
import 'package:hgdj/hj_page/video/view/video_detail_bottom_menu.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'package:hgdj/tools_base/widget/shrink_wrap.dart';
import 'package:video_player/video_player.dart';
import '../../../alert/video/share_media_dialog.dart';
import '../../../hj_model/acg/comic_chapters_model.dart';
import '../../../hj_model/cartoon_media_info.dart';
import '../../cartoon/cartoon_recommend_page.dart';
import 'cartoon_episode_menu_alert.dart';
/// 动漫视频详情页 tab 第 0 页:作品信息 + 选集 + 推荐
class VideoDetailCartoonView extends StatefulWidget {
final VideoModel? model;
final ComicChapterInfo? currentEpisode;
final VideoPlayerController? playCtr;
final Function(VideoModel model)? vmCallback;
final Function(ComicChapterInfo model)? episoCallback; // 动漫选集回调
const VideoDetailCartoonView({
super.key,
this.model,
this.playCtr,
this.vmCallback,
this.currentEpisode,
this.episoCallback,
});
@override
State<VideoDetailCartoonView> createState() => _VideoDetailCartoonViewState();
}
class _VideoDetailCartoonViewState extends State<VideoDetailCartoonView>
with SingleTickerProviderStateMixin {
VideoModel? get videoModel => widget.model;
// 当前内容是动漫,动漫推荐按本作 acg tag(mediaInfo.tagDetails) 拉相似作品
String? get _acgTagId => videoModel?.mediaInfo?.tagDetails?.firstOrNull?.id;
late final TabController tabCtr = TabController(length: 3, vsync: this);
// 动漫页菜单顺序跟真人页不一样,视频推荐排在中间
final List<String> menuTitles = const ["动漫推荐", "视频推荐", "漫画推荐"];
@override
void dispose() {
tabCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ExtendedNestedScrollView(
onlyOneScrollInBody: true,
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverToBoxAdapter(
child: Container(
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
child: _buildCartoonInfo(),
),
),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.only(top: 18),
margin: EdgeInsets.symmetric(horizontal: 12),
child:
VideoDetailBottomMenu(model: videoModel, onShare: _onShare),
),
),
SliverToBoxAdapter(
child: Container(
margin: EdgeInsets.fromLTRB(12, 18, 12, 18),
child: 0.5.line,
),
),
SliverToBoxAdapter(
child: VideoCartoonEpisodeMenu(
widget.currentEpisode,
cartoonModel: videoModel?.mediaInfo,
callback: widget.episoCallback,
),
),
SliverToBoxAdapter(
child: Container(
margin: EdgeInsets.fromLTRB(12, 18, 12, 18),
child: 0.5.line,
),
),
SliverToBoxAdapter(
child: AdsGridViewWidget(
6,
padding: EdgeInsets.only(left: 12, bottom: 18),
),
),
SliverToBoxAdapter(child: _buildTitleMenu()),
];
},
body: TabBarView(
// 换播放源后 videoModel.id 变化 → 整个推荐区重建,按新视频刷新
key: ValueKey('rec_${videoModel?.id}'),
controller: tabCtr,
children: [
// 动漫推荐(当前内容同类,带本作 tagId 拉相似)
CartoonRecommendPage(
mediaStyle: MediaStyle.Cartoon,
mediaId: _acgTagId,
childAspectRatio: 111 / 174,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
onAcgTap: _onAcgCellTap,
).keepAlive,
// 视频推荐(非当前内容类型,不带 tagId)
CartoonRecommendPage(
mediaStyle: MediaStyle.Video,
crossAxisCount: 2,
childAspectRatio: 168 / 142,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
onVideoTap: _onVideoCellTap,
).keepAlive,
// 漫画推荐
CartoonRecommendPage(
mediaStyle: MediaStyle.Comics,
childAspectRatio: 111 / 174,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
onAcgTap: _onAcgCellTap,
).keepAlive,
],
),
);
}
/// 分享:弹分享面板
void _onShare() {
Get.dialog(ShareMediaDialog(videoModel: videoModel));
}
/// 动漫作品信息:标题 + 标签(原 VideoDetailCartoonInfoWidget 单处使用,已内联)
Widget _buildCartoonInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_buildRichText(),
_buildCartoonTags(),
],
);
}
Widget _buildRichText() {
if (videoModel?.title?.trim().isNotEmpty == true) {
return Container(
padding: EdgeInsets.only(bottom: 0),
child: Text(
videoModel?.title?.trim() ?? "",
style: TextStyle(
color: Colors.white,
height: 1.5,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
);
}
return SizedBox();
}
Widget _buildCartoonTags() {
if (videoModel?.tags?.isNotEmpty == true) {
return Container(
padding: EdgeInsets.only(top: 12),
child: ShrinkWrap(
spacing: 0,
runSpacing: 6,
maxLines: 1,
children: videoModel!.tags!.map((e) => _buildTagItem(e)).toList(),
),
);
}
return const SizedBox();
}
Widget _buildTagItem(TagsBean tag) {
return InkWell(
enableFeedback: false,
onTap: () {
widget.playCtr?.pause();
Get.to(() => CartoonTagPage(title: tag.name ?? "", sId: tag.id),
preventDuplicates: true);
},
child: Container(
padding: EdgeInsets.fromLTRB(8, 2, 8, 2),
margin: EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
"#${tag.name}",
style: TextStyle(
color: Color(0x73ffffff),
fontSize: 12,
),
),
),
);
}
/// 推荐 tab 标题栏(居中 TabBar + 渐变下划线,与漫画详情页一致)
Widget _buildTitleMenu() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TabBar(
controller: tabCtr,
isScrollable: true,
tabAlignment: TabAlignment.center,
padding: EdgeInsets.symmetric(horizontal: 8),
labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
labelColor: Color(0xE5FFFFFF),
unselectedLabelColor: Color(0x8CFFFFFF),
unselectedLabelStyle:
TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
tabs: menuTitles
.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 5),
child: Text(e),
))
.toList(),
indicator: CustomIndicator(
isGradient: true,
width: 13,
height: 3,
borderRadius: BorderRadius.circular(1.5)),
),
],
);
}
/// 视频推荐 cell 点击:无源地址/短片(<5min) → 暂停并 push 新页;
/// 同一视频 → toast;完整片 → 交 vmCallback 就地换源,不开新页
void _onVideoCellTap(VideoModel acModel) {
if (acModel.sourceURL?.isNotEmpty != true) {
widget.playCtr?.pause();
pushToVideoPage(videoModel: acModel);
return;
}
if (acModel.id == videoModel?.id) {
showToast("当前视频正在播放");
return;
}
// 300 秒 = 5 分钟:短片当预览片,开新页播;长片直接在当前播放器替换源
if ((acModel.playTime ?? 300) < 300) {
widget.playCtr?.pause();
pushToVideoPage(videoModel: acModel);
} else {
widget.vmCallback?.call(acModel);
}
}
/// 动漫/漫画 cell 点击:video 类型交 vmCallback 就地换源,其它跳漫画详情页
void _onAcgCellTap(CartoonMediaInfo acModel) {
widget.playCtr?.pause();
if (acModel.mediaType == 'video') {
// videoType=1 标记为动漫视频,让播放器走 cartoon 分支(区别于普通真人视频)
final vm = VideoModel(id: acModel.id)
..cover = acModel.coverH
..videoType = 1;
widget.vmCallback?.call(vm);
} else {
pushToCartoonPage(acModel);
}
}
}
/// 选集组件:横向 episode 列表 + 「选集」按钮(点击弹 bottomSheet 全集)
class VideoCartoonEpisodeMenu extends StatelessWidget {
final CartoonMediaInfo? cartoonModel;
final ComicChapterInfo? currentEpisode;
final Function(ComicChapterInfo model)? callback;
const VideoCartoonEpisodeMenu(
this.currentEpisode, {
super.key,
this.cartoonModel,
this.callback,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(left: 12),
// StatefulBuilderbottomSheet 关闭后强制刷新选中态,
// 避免父级 callback 异步、选中样式延迟更新
child: StatefulBuilder(builder: (ctx, states) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () async {
await Get.bottomSheet(CartoonEpisodeMenuAlert(
currentEpisode,
cartoonModel: cartoonModel,
callback: (index) => callback?.call(index),
));
states(() {});
},
child: Row(
children: [
Text(
"选集",
style: TextStyle(
fontSize: 18,
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
"${cartoonModel?.totalEpisode ?? 1}",
style: TextStyle(fontSize: 12, color: Color(0xff989898)),
),
3.sizeBoxW,
Icon(
Icons.arrow_forward_ios_rounded,
color: Color(0xff989898),
size: 13,
),
16.sizeBoxW,
],
),
),
const SizedBox(height: 12),
SizedBox(
height: 58,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: cartoonModel?.episodeList?.length ?? 0,
itemBuilder: (context, index) {
final model = cartoonModel!.episodeList![index];
return Container(
width: 58,
height: 58,
margin: const EdgeInsets.only(right: 10),
child: GestureDetector(
onTap: () => callback?.call(model),
child: CartoonChapterItem(
model,
isSelected: currentEpisode?.id == model.id,
fatherM: cartoonModel!,
),
),
);
},
),
),
],
);
}),
);
}
}