初始化
This commit is contained in:
@@ -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),
|
||||
// StatefulBuilder:bottomSheet 关闭后强制刷新选中态,
|
||||
// 避免父级 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!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user