初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../config/config.dart';
import '../../hj_model/cartoon_media_info.dart';
//acg video item组件
class AcgItemWidget extends StatelessWidget {
final CartoonMediaInfo? info;
final bool coverV; //竖屏封面
final bool? heroUI; //是否需要hero动画
final bool? showSymbol; //是否显示角标
final Function()? tapCallback;
const AcgItemWidget({
super.key,
this.info,
this.coverV = true,
this.tapCallback,
this.heroUI = false,
this.showSymbol = true,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: tapCallback ?? () => pushToCartoonPage(info),
child: Column(
children: [
Expanded(
child: Stack(
children: [
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return NetworkImageLoader(
imageUrl: coverV ? info?.coverV ?? '' : info?.coverH ?? '',
width: constraints.maxWidth,
height: constraints.maxHeight,
fit: BoxFit.cover,
);
},
),
Positioned(
right: 6,
top: 6,
child: _buildLevelIcon(),
),
if (info?.isAudiobooks == true)
Positioned(
left: 8,
top: 8,
height: 24,
width: 24,
child: Image.asset('voice_noval_icon.png'.videoPath),
)
],
)),
6.sizeBoxH,
Row(
children: [
Expanded(
child: Text(
info?.title ?? '',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
),
)
],
),
4.sizeBoxH,
Row(
children: [
Expanded(
child: Text(
'${info?.episodeNumberStatus ?? ''} · ${info?.updateDesc}',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 11,
color: _statusColor,
fontWeight: FontWeight.w400),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
)
],
),
);
}
Widget _buildLevelIcon() {
if (info?.permission == 1 && Config.coinMark)
return _levelTag('金币', const Color(0xff8B3E00));
if (info?.permission == 0 && Config.vipMark)
return _levelTag('VIP', const Color(0xff141414));
return const SizedBox.shrink();
}
// 金币/VIP 角标:渐变底 + 文字,仅文字与文字色不同
Widget _levelTag(String text, Color textColor) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(2)),
gradient:
LinearGradient(colors: [Color(0xffFFE580), Color(0xffFACC15)]),
),
child: Text(
text,
style: TextStyle(color: textColor, fontSize: 10, height: 1.6),
),
);
}
Color get _statusColor {
return info?.updateStatus == 1
? const Color(0xffEEC76B)
: Colors.white.withValues(alpha: .45);
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/acg/comic_chapters_model.dart';
import 'package:hgdj/hj_page/live/live_widget.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
import '../../assets_tool/app_colors.dart';
import '../../hj_utils/widget_util.dart';
import 'widget/free_badge.dart';
//有声小说 选择子集
class AudioPlayerBottomSheet extends StatefulWidget {
final String title;
final List<ComicChapterInfo> allAudiobooks;
final Function(int)? indexHandler;
final int? currentPlayIndex;
final String? unit; //子集单位(章/集...),与详情子集 title 格式统一
final bool hasPermission; //整本是否已解锁:false 时前 N 集显示「免费」角标
AudioPlayerBottomSheet(
this.title, {
super.key,
required this.allAudiobooks,
this.indexHandler,
this.currentPlayIndex,
this.unit,
this.hasPermission = false,
});
@override
State<AudioPlayerBottomSheet> createState() => _AudioPlayerBottomSheetState();
}
class _AudioPlayerBottomSheetState extends State<AudioPlayerBottomSheet> {
int sortType = 1; //0-降序,1-升序
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
constraints: BoxConstraints(maxHeight: Get.height / 2),
decoration: const BoxDecoration(
color: AppColors.primaryColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Center(child: SheetHandleBar(color: Color(0xff989898))),
18.sizeBoxH,
Row(
children: [
Text(
widget.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
Spacer(),
InkWell(
enableFeedback: false,
onTap: () => setState(() => sortType = sortType == 1 ? 0 : 1),
child: Row(
children: [
AnimatedRotation(
turns: sortType == 1 ? 0 : 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
child: Image.asset('text_play_sort.png'.acgImgPath,
width: 18),
),
4.sizeBoxW,
Text(
sortType == 1 ? '正序' : '倒序',
style: textStyle(12, Colors.white.withValues(alpha: .45),
FontWeight.w400),
),
],
),
)
],
),
18.sizeBoxH,
Expanded(
child: ListView.separated(
key: ValueKey(sortType), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场
separatorBuilder: (context, index) => Divider(
height: 0.5,
color: Colors.white.withValues(alpha: .05),
),
itemCount: widget.allAudiobooks.length,
itemBuilder: (context, index) {
final realIndex = getRealIndex(index);
final model = widget.allAudiobooks[realIndex];
final select = widget.currentPlayIndex == realIndex;
return StaggerInItem(
index: index,
child: InkWell(
enableFeedback: false,
onTap: () {
Get.back(result: realIndex);
widget.indexHandler?.call(realIndex);
},
child: Container(
padding: const EdgeInsets.only(bottom: 6),
child: Column(
children: [
16.sizeBoxH,
Row(
children: [
if (select) ...[
AudioWaveView(),
8.sizeBoxW,
],
Expanded(
child: Text(
//与详情/抽屉子集 title 格式一致:第X章
'${model.episodeNumber ?? 1}${widget.unit ?? ''}',
style: TextStyle(
color: select
? AppColors.actionRed
: Colors.white
.withValues(alpha: .9),
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
//无任何权限时,前 N 集免费展示「免费」角标
if (widget.hasPermission == false &&
model.inFreeEpisode) ...[
8.sizeBoxW,
const FreeBadge(),
],
8.sizeBoxW,
const Icon(
Icons.navigate_next,
size: 18,
color: Color(0xffDCDCDC),
)
],
),
16.sizeBoxH,
],
)),
));
},
),
)
],
),
);
}
int getRealIndex(int index) {
return (sortType == 1) ? index : widget.allAudiobooks.length - index - 1;
}
}
@@ -0,0 +1,372 @@
import 'package:flutter/cupertino.dart';
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/assets_tool/images.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'audio_player_bottomsheet.dart';
import 'widget/acg_source_manager.dart';
// 播放页面
class AudioPlayerContent extends StatefulWidget {
final int index;
final AudioPlayerManager manager;
final ACGSourceManager sourceManager;
const AudioPlayerContent(this.sourceManager,
{super.key, required this.manager, this.index = 0});
@override
State<AudioPlayerContent> createState() => _AudioPlayerContentState();
}
class _AudioPlayerContentState extends State<AudioPlayerContent> {
AudioPlayerManager get manager => widget.manager;
AudioPlayMod playMod = AudioPlayMod.loopList;
@override
void initState() {
super.initState();
// manager 的 initPlayer / dispose 由 VoiceNovelReadLogic 统管:
// - onInit 已 initPlayer
// - onClose 已 dispose
// 此处不要再调 initPlayer,否则 4 个 stream listener 重复注册导致旧 sub 泄漏
manager
.play(widget.sourceManager.allEpisodes[widget.index].getRealAudioUrl);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: manager.audioValue,
builder: (ctx, audioValue, child) {
double max = (audioValue.duration?.inSeconds ?? 1).toDouble();
if (max == 0) max = 1;
final position = (audioValue.position?.inSeconds ?? 0).toDouble();
final progress = position / max;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
12.sizeBoxH,
NetworkImageLoader(
imageUrl: widget.sourceManager.mediaInfo?.coverH ?? '',
width: 111,
height: 148,
),
18.sizeBoxH,
Row(
children: [
Text(
'正在播放:第${(audioValue.onPlayIndex ?? 0) + 1}',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w500),
),
],
),
18.sizeBoxH,
_progressRow(audioValue, progress, max),
14.sizeBoxH,
_controlRow(audioValue),
18.sizeBoxH,
],
);
},
);
}
// 进度条行:左右 ±10s 快进退、中间滑块、两侧时间
Widget _progressRow(
AudioPlayerValue audioValue, double progress, double max) {
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
enableFeedback: false,
onTap: () => manager.seekTenMinsWithDirection(-1),
child: Image.asset('noval_time_back.png'.acgImgPath, width: 24),
),
8.sizeBoxH,
// 38px:原 35px 部分机型会裁掉 "MM:SS" 末位
SizedBox(
width: 38,
height: 19,
child: Text(
buildMMSS(audioValue.position?.inSeconds ?? 0),
maxLines: 1,
softWrap: false,
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
],
),
10.sizeBoxW,
Expanded(
child: AudioSlider(
value: progress,
onChange: (value) => manager.seek((max * value).toInt()), //进度
),
),
10.sizeBoxW,
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
InkWell(
enableFeedback: false,
onTap: () => manager.seekTenMinsWithDirection(1),
child:
Image.asset('noval_time_forword.png'.acgImgPath, width: 24),
),
8.sizeBoxH,
SizedBox(
height: 19,
width: 38,
child: audioValue.duration == null
? const CupertinoActivityIndicator(color: AppColors.actionRed)
: Text(
buildMMSS(audioValue.duration!.inSeconds),
maxLines: 1,
softWrap: false,
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
),
),
),
],
),
],
);
}
// 控制行:循环模式、上一集/播放暂停/下一集、播放列表
Widget _controlRow(AudioPlayerValue audioValue) {
final isLoop = playMod == AudioPlayMod.loop;
final isPlaying = audioValue.isPlaying ?? false;
return Row(
children: [
// 循环模式切换
InkWell(
enableFeedback: false,
onTap: () => setState(() {
playMod = isLoop ? AudioPlayMod.loopList : AudioPlayMod.loop;
manager.setPlayMod(playMod);
}),
child: Column(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: Image.asset(
isLoop
? 'single_cycle.png'.acgImgPath
: 'list_cycle.png'.acgImgPath,
// key 必须按状态区分,AnimatedSwitcher 才会把它当成新 child 触发动画
key: ValueKey(isLoop ? 'loop' : 'loop_list'),
width: 24,
),
),
2.sizeBoxH,
Text(
isLoop ? '单曲循环' : '多集循环',
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 12,
fontWeight: FontWeight.w400),
),
],
),
),
const Spacer(),
Row(
children: [
InkWell(
enableFeedback: false,
onTap: () => manager.playForward(),
child: Image.asset('forward.png'.acgImgPath, width: 24),
),
40.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () => isPlaying ? manager.pause() : manager.resume(),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: Image.asset(
isPlaying ? 'pause.png'.acgImgPath : 'play.png'.acgImgPath,
key: ValueKey(isPlaying ? 'pause' : 'play'),
width: 36,
),
),
),
40.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () => manager.playNext(),
child: Image.asset('next.png'.acgImgPath, width: 24),
),
],
),
const Spacer(),
InkWell(
enableFeedback: false,
onTap: _showPlaylist,
child: Column(
children: [
Image.asset('play_list.png'.acgImgPath, width: 24),
2.sizeBoxH,
Text(
'播放列表',
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 12,
fontWeight: FontWeight.w400),
),
],
),
),
],
);
}
// 弹出播放列表,点击切集
void _showPlaylist() {
Get.bottomSheet(AudioPlayerBottomSheet(
manager.sourceManger?.mediaInfo?.title ?? '',
currentPlayIndex: widget.sourceManager.index,
allAudiobooks: manager.sourceManger?.allEpisodes ?? [],
indexHandler: (index) => manager.playWithIndex(index),
unit: manager.sourceManger?.mediaInfo?.unit,
hasPermission: manager.sourceManger?.mediaInfo?.hasPermission ?? false,
));
}
}
/// 播放器进度条:内置 Material Slider,拖动/点击由框架处理;
/// 轨道和 thumb 用自定义 shape 套 hgdj 的橙红配色。
class AudioSlider extends StatelessWidget {
final double? value;
final Function(double value) onChange;
const AudioSlider({super.key, required this.value, required this.onChange});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 24,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2,
overlayShape: SliderComponentShape.noOverlay,
trackShape: GradientTrackShape(),
thumbShape: const GradientThumbShape(radius: 8),
),
child: Slider(
min: 0,
max: 1,
value: (value ?? 0).clamp(0.0, 1.0),
onChanged: onChange,
),
),
);
}
}
/// 进度轨道:左侧(已播放)填 actionRed,右侧(未播放)填灰色
class GradientTrackShape extends SliderTrackShape {
@override
Rect getPreferredRect({
bool isDiscrete = false,
bool isEnabled = false,
Offset offset = Offset.zero,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
}) {
final trackHeight = sliderTheme.trackHeight ?? 2;
final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
return Rect.fromLTWH(
offset.dx, trackTop, parentBox.size.width, trackHeight);
}
@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 trackRect = getPreferredRect(
parentBox: parentBox,
sliderTheme: sliderTheme,
offset: offset,
);
final activePaint = Paint()..color = AppColors.actionRed;
final inactivePaint = Paint()..color = const Color(0xff3D3D3D);
// 左侧已播放
context.canvas.drawRect(
Rect.fromLTRB(
trackRect.left, trackRect.top, thumbCenter.dx, trackRect.bottom),
activePaint,
);
// 右侧未播放
context.canvas.drawRect(
Rect.fromLTRB(
thumbCenter.dx, trackRect.top, trackRect.right, trackRect.bottom),
inactivePaint,
);
}
}
/// 圆形 thumb:外圈半透明 actionRed,内圈白
class GradientThumbShape extends SliderComponentShape {
final double radius;
const GradientThumbShape({this.radius = 8});
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
Size.fromRadius(radius);
@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;
final outerPaint = Paint()
..color = AppColors.actionRed.withValues(alpha: 0.7);
final innerPaint = Paint()..color = Colors.white;
canvas.drawCircle(center, radius, outerPaint);
canvas.drawCircle(center, radius - 3, innerPaint);
}
}
@@ -0,0 +1,109 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.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 '../../hj_utils/history_util.dart';
import '../../tools_base/event_bus/event_bus_util.dart';
import 'cartoon_read_page.dart';
import 'widget/acg_source_manager.dart';
import 'widget/cartoon_subset_alert.dart';
class CartoonDetailLogic extends GetxController
with GetTickerProviderStateMixin {
String? id;
CartoonMediaInfo? mediaInfo;
bool showHeader = true;
double showOffset = 245;
bool get isCartoonType => manager.mediaInfo?.mediaType == 'image'; //是否是动画
// 推荐 tab 固定 3 个(漫画/视频/动漫),顺序由页面按当前内容类型动态排
late TabController tabCtr = TabController(length: 3, vsync: this);
late ScrollController scrollCtr = ScrollController()
..addListener(() {
//滚过阈值切换顶部沉浸 header,状态变了才刷新
final show = scrollCtr.offset <= showOffset;
if (show != showHeader) {
showHeader = show;
update(['header']);
}
});
final ItemScrollController itemSCtrl = ItemScrollController();
late ACGSourceManager manager;
late StreamSubscription subscription;
CartoonDetailLogic({this.id})
: manager = ACGSourceManager(id ?? ''); //初始化,加载子集
@override
void onReady() async {
super.onReady();
// subscription 是 late,必须在任何 await 前初始化,
// 否则详情加载途中退出会让 onClose 的 cancel() 打到未初始化字段而崩
subscription = eventBus.on<ACGMenuChanged>((event) {
//列表未 attach(未 build/布局完)时 jumpTo 内部会 ! 取空崩溃,加保护
if (itemSCtrl.isAttached) {
itemSCtrl.jumpTo(index: event.index ?? 0);
}
});
// 先拿详情(含 freeEpisode),再拉子集,才能正确标记前 N 集免费
await loadData();
manager.fetchAllEpisodes();
}
@override
void onClose() {
subscription.cancel();
eventBus.off(subscription);
tabCtr.dispose(); //修复泄漏
scrollCtr.dispose(); //修复泄漏
// manager 由页面的 ChangeNotifierProvider(create:) 在卸载时 dispose,本类不再重复释放
super.onClose();
}
//获取媒体详情
Future loadData() async {
mediaInfo = await ACGService.getMediaInfo(id ?? '');
if (mediaInfo != null) {
HistoryUtil.insert(mediaInfo!, MediaStyle.Comics);
manager.mediaInfo = mediaInfo;
showOffset = mediaInfo?.mediaType == 'text' ? 300 : 320;
} else {
mediaInfo = CartoonMediaInfo();
}
update();
}
//切换集数
changeEpisode(int episode, bool jump) async {
manager.changeEpisodesIndex(episode, jump: jump);
play(); //播放
}
play() async {
//判断权限
ComicChapterInfo? info =
await manager.getEpisodeHasPermisson(manager.index);
if (info == null) return;
Get.to(() => CartoonReadPage(manager: manager..index = manager.index));
}
//集数弹窗
onMenuAction() async {
int episode = await Get.bottomSheet(
CartoonSubSetAlert(sourceManager: manager),
barrierColor: Colors.black.withValues(alpha: .2),
);
if (episode > -1) {
changeEpisode(episode, true);
}
}
}
@@ -0,0 +1,593 @@
import 'dart:math';
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:hgdj/tools_base/widget/follow_button.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../../../hj_utils/const.dart';
import '../../../hj_utils/screen.dart';
import '../../../tools_base/loading/loading_center_widget.dart';
import '../../alert/video/share_media_dialog.dart';
import '../../hj_model/video_model.dart';
import '../../hj_utils/widget_util.dart';
import '../home/tag/cartoon_tag_page.dart';
import 'cartoon_detail_logic.dart';
import 'cartoon_recommend_page.dart';
import 'widget/acg_expand_text.dart';
import 'widget/acg_source_manager.dart';
import 'widget/free_badge.dart';
//acg详情页,小说和漫画 合并
class CartoonDetailPage extends StatefulWidget {
final String id;
final String? heroTag;
const CartoonDetailPage({super.key, this.id = '', this.heroTag});
@override
State<CartoonDetailPage> createState() => _CartoonDetailPageState();
}
// 详情页可经「推荐页 → 再开详情页」成环并存,用 UniqueTagMixin 给每个实例唯一 tag
class _CartoonDetailPageState extends State<CartoonDetailPage>
with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xff05000E),
body: GetBuilder<CartoonDetailLogic>(
tag: uniqueTag,
init: CartoonDetailLogic(id: widget.id),
builder: (logic) {
// manager 作为 ChangeNotifier 供子树 Consumer 监听;
// 用 create: 让 Provider 在页面卸载时自动 dispose manager(释放音频播放器+订阅),Flutter 层保证释放
return ChangeNotifierProvider(
create: (_) => logic.manager,
child: Builder(builder: (_) {
if (logic.mediaInfo == null) return LoadingCenterWidget();
if (logic.mediaInfo?.id == null) {
return CErrorWidget(retryOnTap: () => logic.loadData());
}
return _buildContent(logic);
}),
);
},
),
);
}
// 当前内容对应的推荐类型(小说等无对应 tab 时为 null)
MediaStyle? _selfStyle(CartoonDetailLogic logic) =>
switch (logic.mediaInfo?.mediaType) {
'video' => MediaStyle.Cartoon,
'image' => MediaStyle.Comics,
_ => null,
};
// 推荐 tab 排序:与当前内容同类的排最前,其余按默认顺序
List<MediaStyle> _orderedStyles(CartoonDetailLogic logic) {
const base = [MediaStyle.Comics, MediaStyle.Video, MediaStyle.Cartoon];
final self = _selfStyle(logic);
if (self == null) return base;
return [self, ...base.where((s) => s != self)];
}
String _recTitle(MediaStyle s) => switch (s) {
MediaStyle.Video => '视频推荐',
MediaStyle.Cartoon => '动漫推荐',
_ => '漫画推荐',
};
// 单个推荐 tab:同类才传 tagId(拉相似),视频 cell 用大图 2 列
Widget _recPage(CartoonDetailLogic logic, MediaStyle s) {
final isMatch = s == _selfStyle(logic);
final isVideo = s == MediaStyle.Video;
return CartoonRecommendPage(
mediaStyle: s,
mediaId: isMatch ? logic.mediaInfo?.tagDetails?.firstOrNull?.id : null,
crossAxisCount: isVideo ? 2 : 3,
childAspectRatio: isVideo ? 168 / 142 : 111 / 188,
).keepAlive;
}
Widget _buildContent(CartoonDetailLogic logic) {
final styles = _orderedStyles(logic);
return Column(
children: [
Expanded(
child: Stack(
children: [
ExtendedNestedScrollView(
controller: logic.scrollCtr,
onlyOneScrollInBody: true,
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverToBoxAdapter(child: _buildImgHeader(logic)), //header
SliverToBoxAdapter(child: _buildMenu(logic)), //目录
SliverToBoxAdapter(
child: _buildTabbar(logic, styles)) //推荐 tabbar
];
},
//推荐
body: TabBarView(
controller: logic.tabCtr,
children: styles.map((s) => _recPage(logic, s)).toList(),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildTopHeader(logic),
),
],
)),
Padding(
// 底部垫上虚拟导航栏高度,避免按钮被遮挡(edge-to-edge
padding: EdgeInsets.only(top: 10, bottom: 10 + screen.paddingBottom),
child: Row(
children: [
16.sizeBoxW,
if (logic.isCartoonType) ...[
Consumer<ACGSourceManager>(
builder: (context, manager, child) {
bool liked =
manager.mediaInfo?.mediaStatus?.hasLiked == true;
return InkWell(
enableFeedback: false,
onTap: () => manager.onLikeAction(),
child: Container(
width: 108,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .2),
borderRadius: BorderRadius.circular(3),
),
height: 44,
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
liked
? 'acg_like_sel.png'.acgImgPath
: 'acg_like_nor.png'.acgImgPath,
width: 16,
),
10.sizeBoxW,
Text(
'喜欢',
style: textStyle(
16, Color(0xff989898), FontWeight.w500),
),
],
)),
);
},
),
12.sizeBoxW,
],
Expanded(
child: InkWell(
enableFeedback: false,
onTap: () => logic.play(),
child: Container(
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
height: 44,
alignment: Alignment.center,
child: Text(
'开始阅读',
style: textStyle(16, Colors.white, FontWeight.w500),
),
),
),
),
16.sizeBoxW,
],
),
),
],
);
}
Widget _buildImgHeader(CartoonDetailLogic logic) {
return Stack(
children: [
//背景图
Positioned(
top: 0,
left: 0,
right: 0,
child: NetworkImageLoader(
imageUrl: logic.mediaInfo?.verticalCover ?? "",
borderRadius: 0,
height: 350,
alignment: Alignment.topCenter,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
(10 + screen.paddingTop).sizeBoxH,
Row(
children: [
_buildBackView(),
Spacer(),
_buildCollAction(),
12.sizeBoxW,
_buildShareView(logic),
],
).paddingSymmetric(horizontal: 16),
154.sizeBoxH,
Text(
logic.mediaInfo?.title ?? "",
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w900,
fontFamily: 'Roboto',
),
).paddingSymmetric(horizontal: 16),
12.sizeBoxH,
Container(
padding: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Color(0xff05000E),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
children: [
18.sizeBoxH,
Row(
children: [
_buildStateItem(
'view_count.png'.communityPath,
logic.mediaInfo?.countBrowse?.countStr ?? '',
),
12.sizeBoxW,
_buildSpe(),
12.sizeBoxW,
// 点赞状态/数量随 manager.notifyListeners 刷新(onLikeAction 不会触发 GetX update
Consumer<ACGSourceManager>(
builder: (_, manager, __) => _buildStateItem(
manager.mediaInfo?.mediaStatus?.hasLiked == true
? 'community_thumb_sel.png'.communityPath
: 'like_count.png'.communityPath,
manager.mediaInfo?.countLike?.countStr ?? '',
),
),
12.sizeBoxW,
_buildSpe(),
12.sizeBoxW,
// 0:会员 1:金币购买 2:免费
if (logic.mediaInfo?.permission == 0) ...[
Text(
'VIP',
style:
textStyle(12, Color(0xff989898), FontWeight.w400),
),
] else if (logic.mediaInfo?.permission == 1) ...[
Text(
'${logic.mediaInfo?.price ?? 0}金币',
style:
textStyle(12, Color(0xff989898), FontWeight.w400),
),
],
],
),
//详情
if (logic.mediaInfo?.summary?.isNotEmpty == true) ...[
12.sizeBoxH,
Padding(
padding: EdgeInsets.fromLTRB(0, 12, 0, 0),
child: AcgShowMoreTextWidget(
summary: logic.mediaInfo?.summary ?? '',
maxWidth: Get.width - 10,
),
)
],
//标签
if ((logic.mediaInfo?.tagDetails?.length ?? 0) != 0) ...[
12.sizeBoxH,
SizedBox(
height: 26,
child: ListView.separated(
separatorBuilder: (context, index) => 6.sizeBoxW,
itemCount: logic.mediaInfo?.tagDetails?.length ?? 0,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
TagsBean? tag = logic.mediaInfo?.tagDetails?[index];
return InkWell(
enableFeedback: false,
onTap: () {
Get.to(CartoonTagPage(
title: tag?.name ?? '',
sId: tag?.id ?? '',
));
},
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(3)),
height: 26,
child: Text(
tag?.name ?? '',
style: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 12,
fontWeight: FontWeight.w400,
),
)),
);
},
),
),
12.sizeBoxH,
],
],
),
),
],
),
],
);
}
Widget _buildSpe() {
return Container(
color: Colors.white.withValues(alpha: 0.1),
height: 18,
width: 0.5,
);
}
Widget _buildStateItem(String img, String name) {
return Row(
children: [
Image.asset(img, width: 16),
4.sizeBoxW,
Text(
name,
style: textStyle(12, Color(0xff989898), FontWeight.w400),
)
],
);
}
Widget _buildTabbar(CartoonDetailLogic logic, List<MediaStyle> styles) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TabBar(
controller: logic.tabCtr,
padding: EdgeInsets.symmetric(horizontal: 8),
tabs: styles
.map((s) => Padding(
padding: const EdgeInsets.only(bottom: 5.0),
child: Text(_recTitle(s)),
))
.toList(),
tabAlignment: TabAlignment.center,
isScrollable: true,
labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
labelColor: Colors.white.withValues(alpha: 0.9),
unselectedLabelColor: Colors.white.withValues(alpha: .55),
unselectedLabelStyle:
TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
indicator: CustomIndicator(
isGradient: true,
width: 13,
height: 3,
borderRadius: BorderRadius.circular(1.5),
)),
],
);
}
Widget _buildMenu(CartoonDetailLogic logic) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
18.sizeBoxH,
Row(
children: [
Text(
logic.isCartoonType
? '选集'
: '${logic.mediaInfo?.totalEpisode ?? 0}${logic.mediaInfo?.unit}',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
Spacer(),
Row(
children: [
InkWell(
enableFeedback: false,
onTap: logic.onMenuAction,
child: Text(
logic.isCartoonType
? '${logic.mediaInfo?.totalEpisode ?? 0}${logic.mediaInfo?.unit}'
: '目录',
style: TextStyle(
color: Color(0xFFA7A7A7),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
2.sizeBoxW,
Image.asset(
'arrow_right_grey.webp'.commonImgPath,
color: Color(0xff989898),
width: 24,
)
],
)
],
),
18.sizeBoxH,
Consumer<ACGSourceManager>(
builder: (context, provider, child) {
return ListView.builder(
padding: EdgeInsets.zero,
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemBuilder: (context, index) {
final epm = provider.allEpisodes[index];
return InkWell(
enableFeedback: false,
onTap: () => logic.changeEpisode(index, false),
child: Container(
padding: EdgeInsets.symmetric(
vertical: logic.isCartoonType ? 6 : 12),
child: Row(
children: [
Flexible(
child: Text(
'${epm.episodeNumber ?? ''}${provider.mediaInfo?.unit}${logic.isCartoonType ? '' : ' ${epm.name}'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle(
16,
provider.index == index
? Colors.white.withValues(alpha: .9)
: Colors.white.withValues(alpha: .45),
provider.index == index
? FontWeight.w500
: FontWeight.w400,
),
),
),
//无任何权限时,前 N 集免费展示「免费」角标
if (provider.mediaInfo?.hasPermission == false &&
epm.inFreeEpisode) ...[
8.sizeBoxW,
const FreeBadge(),
],
],
),
),
);
},
itemCount: min(4, provider.allEpisodes.length),
);
},
),
18.sizeBoxH,
if (logic.isCartoonType) 0.5.line,
18.sizeBoxH,
],
),
);
}
Widget _buildBackView() {
return InkWell(
enableFeedback: false,
child: Image.asset(
'back_circle_grey.png'.commonImgPath,
width: 24,
),
onTap: () => Get.back(),
);
}
Widget _buildShareView(CartoonDetailLogic logic) {
return InkWell(
enableFeedback: false,
onTap: () {
Get.dialog(
ShareMediaDialog(
videoModel: VideoModel()
..id = logic.mediaInfo?.id
..cover = logic.mediaInfo?.coverH,
),
);
},
child: Image.asset("acg_share.png".acgImgPath, width: 24),
);
}
Widget _buildTopHeader(CartoonDetailLogic logic) {
return GetBuilder<CartoonDetailLogic>(
tag: uniqueTag,
id: 'header',
builder: (_) {
return Offstage(
offstage: logic.showHeader,
child: Container(
padding:
EdgeInsets.only(left: 16, right: 16, top: screen.paddingTop),
height: 44 + screen.paddingTop,
color: Color(0xff05000E),
child: Row(
children: [
GestureDetector(
onTap: () => Get.back(),
child:
Icon(Icons.arrow_back_ios, size: 24, color: Colors.white),
),
8.sizeBoxW,
Expanded(
child: Text(
logic.mediaInfo?.title ?? '',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
_buildCollAction(),
12.sizeBoxW,
_buildShareView(logic),
],
),
),
);
},
);
}
Widget _buildCollAction() {
return Consumer<ACGSourceManager>(
builder: (context, manager, child) {
bool col = manager.mediaInfo?.mediaStatus?.hasCollected ?? false;
return FollowButton(
mediaId: manager.mediaInfo?.id ?? '',
isFollow: col,
followType: FollowEnum.cartoon,
successsAction: (isSuccess) {
manager.updateCollectState(isSuccess);
if (isSuccess) {
showToast('收藏成功');
}
},
);
},
);
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/media_content.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart';
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart';
import 'widget/acg_source_manager.dart';
class CartoonReadLogic extends GetxController {
final ACGSourceManager manager;
MediaContent? detailInfo;
int currentEpisode = 0; // 当前子集数,从 0 开始
bool showMenu = false;
int readIndex = 1; // 当前在第几张图
bool showProgress = false; // 展示进度
bool sortType = true; // 排序类型 true-正序 false-倒序
bool autoPlay = false; // 是否自动播放
double readScale = 0; // 播放比例
double bottomHeight = 155;
int animationTime = 250; // 显示时间(ms)
Timer? _timer;
bool _isSeeking = false; // 拖进度条中:此时的滚动由 slider 主导,别让滚动回调反过来改 slider/菜单
int _seekTarget = -1; // 本次拖动已跳到的图,-1 表示没在拖
final ItemScrollController itemScrollCtr = ItemScrollController();
final ScrollOffsetController scrollOffsetCtr = ScrollOffsetController();
final ItemPositionsListener itemListener = ItemPositionsListener.create();
int get imgsLength => detailInfo?.urlSet?.length ?? 1;
// 最后一张图的下标,无图时给 0,避免 clamp 传入负数上界
int get _maxImgIndex => imgsLength > 0 ? imgsLength - 1 : 0;
CartoonReadLogic(this.manager) : currentEpisode = manager.index;
@override
void onInit() {
super.onInit();
itemListener.itemPositions.addListener(_onItemPositionsChange);
}
@override
void onReady() {
super.onReady();
loadData();
}
@override
void onClose() {
_stopTimer();
itemListener.itemPositions.removeListener(_onItemPositionsChange);
super.onClose();
}
// ========== 公开方法 ==========
// 首次进入加载当前章(也用于错误页重试,返回 Future 才有 loading + 防连点)
Future<void> loadData() => _openEpisode(currentEpisode, isFirstLoad: true);
// true 下一集 / false 上一集
void loadOtherEpisode(bool next) {
final index = next ? currentEpisode + 1 : currentEpisode - 1;
if (index < 0) {
showToast('这是第一${manager.mediaInfo?.unit}了喔~');
return;
}
if (index > manager.allEpisodes.length - 1) {
showToast('这是最后一${manager.mediaInfo?.unit}了喔~');
return;
}
_openEpisode(index);
}
// 目录点击切章
void loadDataWithIndex(String id) {
// 目录列表可能倒序,按 id 定位 manager 中真实下标(不依赖"集号==下标+1",也天然不会越界)
final realIndex = manager.allEpisodes.indexWhere((e) => e.id == id);
if (realIndex < 0) {
showToast('数据错误,请联系客服');
return;
}
_openEpisode(realIndex);
}
// 拖动进度条跳图:跨图才跳,同一张图内的细微拖动不重复 jumpTo
void updateReadPicIndex(double value) {
_isSeeking = true;
readScale = value;
update(['slider']);
// 和 _seekTarget 比而不是和 readIndex 比:readIndex 是「最后一张可见图」,
// 这里的 target 是「要对齐到顶部的图」,两者语义不同,混用会漏跳
final target = (_maxImgIndex * value).round(); // 按最大下标折算,天然不越界
if (target == _seekTarget) return;
_seekTarget = target;
readIndex = target;
if (itemScrollCtr.isAttached) itemScrollCtr.jumpTo(index: target);
}
// 松手:jumpTo 的位置回调下一帧才到,延后一帧再放开
void onSeekEnd(double value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// 拖动期间位置回调被屏蔽,松手补一次触底结算(停自动播放);
// 放在解锁之前,免得这一步引起的滚动又被 onScrollAction 收了菜单
_checkReachBottom();
_isSeeking = false;
_seekTarget = -1;
});
// 没有待渲染的帧时 postFrame 回调会一直挂着,这里确保有一帧
WidgetsBinding.instance.ensureVisualUpdate();
}
void changeShowMenu() {
showMenu = !showMenu;
update(['header', 'footer', 'like']);
}
// 用户点「自动」按钮:开启的同时收起菜单
void onChangeAutoPlay(bool play) {
showMenu = !play;
if (play) {
update(['header', 'footer', 'autoplay', 'like']);
Future.delayed(const Duration(milliseconds: 300)).then((_) {
autoPlay = true;
update(['autoplay']);
});
_startTimer();
} else {
_stopTimer();
autoPlay = false;
update(['header', 'footer', 'autoplay', 'like']);
}
}
void toggleProgress() {
showProgress = !showProgress;
update(['progress']);
}
void toggleSort() {
sortType = !sortType;
update(['menu']);
}
void onScrollAction(ScrollController scrollController) {
// 拖进度条引起的滚动不能收菜单,否则进度条自己滑出屏幕
if (_isSeeking || !showMenu) return;
showMenu = false;
update(['header', 'footer', 'like']);
}
// ========== 私有方法 ==========
/// 切章统一入口:权限/缓存都在 manager 里,这里只管状态重置和刷新
/// [isFirstLoad] 失败时才进错误页
Future<void> _openEpisode(int index, {bool isFirstLoad = false}) async {
// 首次进入页面已有全屏 loading,不再叠遮罩
final model = await manager.getComicsMediaContent(
index: index, showLoading: !isFirstLoad);
if (model == null) {
if (isFirstLoad)
detailInfo = MediaContent(); // 空 model → id 为 null → 页面走错误态
update();
return;
}
currentEpisode = index;
detailInfo = model;
readIndex = 0;
readScale = 0;
update();
// 首次加载时列表还没 buildcontroller 未 attach,跳转会空断言崩
if (itemScrollCtr.isAttached) itemScrollCtr.jumpTo(index: 0);
}
void _onItemPositionsChange() {
if (_isSeeking) return; // 拖动中:slider 值已由手指决定,再反写会和 jumpTo 来回抖
final positions = itemListener.itemPositions.value;
if (positions.isEmpty) return; // 无图/列表重建时为空,取 first 会抛 StateError
readIndex = positions.last.index;
_checkReachBottom();
// 分母用最大下标,滚到底才是满进度
final count = detailInfo?.urlSet?.length ?? 0;
readScale = count > 1 ? (readIndex / (count - 1)).clamp(0.0, 1.0) : 0;
update(['slider']);
}
// 触底:停自动播放(顺带弹回菜单)
void _checkReachBottom() {
final positions = itemListener.itemPositions.value;
if (positions.isEmpty) return;
final count = detailInfo?.urlSet?.length ?? 0;
// 首图还在视野内就不算触底(整章不足一屏时不处理)
if (positions.first.index == 0 || positions.last.index != count - 1) return;
onChangeAutoPlay(false);
}
void _startTimer() {
_timer?.cancel(); // 防重入:先停旧的再建新的,避免重复开启时旧定时器泄漏
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) {
scrollOffsetCtr.animateScroll(
offset: 50, duration: const Duration(milliseconds: 500));
});
}
void _stopTimer() {
_timer?.cancel();
_timer = null;
}
}
+511
View File
@@ -0,0 +1,511 @@
import 'dart:math';
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/assets_tool/images.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/comment/comment_alert.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/follow_button.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart';
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
import 'package:provider/provider.dart';
import '../../alert/video/share_media_dialog.dart';
import '../../hj_utils/widget_util.dart';
import 'cartoon_read_logic.dart';
import 'widget/acg_source_manager.dart';
// 漫画阅读页面
class CartoonReadPage extends StatelessWidget {
final ACGSourceManager manager;
const CartoonReadPage({super.key, required this.manager});
@override
Widget build(BuildContext context) {
return GetBuilder<CartoonReadLogic>(
init: CartoonReadLogic(manager),
builder: (logic) => Scaffold(
body: ChangeNotifierProvider<ACGSourceManager>.value(
value: manager,
child: _buildBody(context, logic),
),
drawer: _buildDrawer(logic),
),
);
}
Widget _buildBody(BuildContext context, CartoonReadLogic logic) {
if (logic.detailInfo == null) return const LoadingCenterWidget();
if (logic.detailInfo?.id == null)
return CErrorWidget(retryOnTap: logic.loadData);
return Stack(
alignment: Alignment.center,
children: [
InkWell(
enableFeedback: false,
onTap: logic.changeShowMenu,
child: ScrollablePositionedList.builder(
scrollAction: logic.onScrollAction,
scrollOffsetController: logic.scrollOffsetCtr,
itemPositionsListener: logic.itemListener,
itemScrollController: logic.itemScrollCtr,
itemCount: logic.imgsLength,
itemBuilder: (_, index) => NetworkImageLoader(
imageUrl: logic.detailInfo?.urlSet?[index] ?? '',
fit: BoxFit.cover,
borderRadius: 0,
),
),
),
_buildHeader(logic),
_buildFooter(context, logic),
_buildLiked(logic),
_buildAutoPlay(logic),
],
);
}
// header bar
Widget _buildHeader(CartoonReadLogic logic) {
final headerHeight = screen.paddingTop + 50;
return GetBuilder<CartoonReadLogic>(
id: 'header',
builder: (_) => AnimatedPositioned(
duration: Duration(milliseconds: logic.animationTime),
top: logic.showMenu ? 0 : -headerHeight,
left: 0,
right: 0,
child: Container(
color: Colors.black,
height: headerHeight,
padding: EdgeInsets.fromLTRB(10, screen.paddingTop + 8, 10, 8),
child: Row(
children: [
InkWell(
enableFeedback: false,
onTap: () => Get.back(),
child: const Icon(Icons.arrow_back_ios_new,
size: 24, color: Colors.white),
),
10.sizeBoxW,
Expanded(
child: Text(
logic.detailInfo?.name ?? '',
style: TextStyle(
fontSize: 18.sp,
color: Colors.white,
fontWeight: FontWeight.w600),
),
),
21.sizeBoxW,
Consumer<ACGSourceManager>(
builder: (ctx, manager, _) {
final col =
manager.mediaInfo?.mediaStatus?.hasCollected ?? false;
return FollowButton(
mediaId: manager.mediaInfo?.id ?? '',
isFollow: col,
followType: FollowEnum.cartoon,
successsAction: (isSuccess) {
manager.updateCollectState(isSuccess);
if (isSuccess) showToast('收藏成功');
},
);
},
),
12.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () => Get.dialog(
ShareMediaDialog(
videoModel: VideoModel()
..id = manager.mediaInfo?.id
..cover = manager.mediaInfo?.coverH,
),
),
child: Image.asset('acg_share.png'.acgImgPath, width: 24),
),
],
),
),
),
);
}
Widget _buildFooter(BuildContext context, CartoonReadLogic logic) {
return GetBuilder<CartoonReadLogic>(
id: 'footer',
builder: (_) => AnimatedPositioned(
duration: Duration(milliseconds: logic.animationTime),
bottom: logic.showMenu ? 0 : -logic.bottomHeight,
left: 0,
right: 0,
child: Column(
children: [
15.sizeBoxH,
GetBuilder<CartoonReadLogic>(
id: 'progress',
builder: (_) => AnimatedSlide(
offset: logic.showProgress ? Offset.zero : const Offset(0, 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
child: _buildProgressView(context, logic),
),
),
Container(
color: Colors.black,
padding: const EdgeInsets.fromLTRB(16, 18, 16, 25),
child: Row(
children: [
Expanded(
child: Builder(
builder: (ctx) => _buildActionItem(
title: '目录',
icon: 'cartoon_menu.png'.acgImgPath,
action: () => Scaffold.of(ctx).openDrawer(),
),
),
),
Expanded(
child: _buildActionItem(
title: '评论',
icon: 'cartoon_comment.png'.acgImgPath,
action: () => showCommentDialog(
logic.manager.mediaInfo?.id ?? '',
objType: logic.manager.mediaInfo?.commentType ?? '',
),
),
),
Expanded(
child: _buildActionItem(
title: '进度',
icon: 'cartoon_progress.png'.acgImgPath,
action: logic.toggleProgress,
),
),
Expanded(
child: _buildActionItem(
title: '自动',
icon: 'cartoon_auto_play.png'.acgImgPath,
action: () => logic.onChangeAutoPlay(true),
),
),
],
),
),
],
),
),
);
}
Widget _buildActionItem(
{String? title, String? icon, VoidCallback? action, Color? color}) {
return InkWell(
enableFeedback: false,
onTap: action,
child: Column(
children: [
Image.asset(icon ?? '', width: 24),
4.sizeBoxH,
Text(
title ?? '',
style: TextStyle(
color: color ?? Colors.white.withValues(alpha: .45),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
);
}
// 进度条 + 上/下一篇
Widget _buildProgressView(BuildContext context, CartoonReadLogic logic) {
return Container(
color: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
InkWell(
enableFeedback: false,
onTap: () => logic.loadOtherEpisode(false),
child: Column(
children: [
Image.asset('common_back_new.png'.commonImgPath, width: 24),
4.sizeBoxH,
Text('上一篇',
style: textStyle(12, Colors.white, FontWeight.w400)),
],
),
),
13.sizeBoxW,
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
thumbShape:
const RoundSliderThumbShape(enabledThumbRadius: 8.0),
overlayShape:
const RoundSliderOverlayShape(overlayRadius: 12.0),
tickMarkShape:
const RoundSliderTickMarkShape(tickMarkRadius: 10.0),
trackShape: const RoundedRectSliderTrackShape(),
overlayColor: Colors.white.withValues(alpha: .3),
activeTrackColor: const Color(0xffFFD460),
inactiveTrackColor: Colors.white.withValues(alpha: .3),
thumbColor: const Color(0xffFFD460),
inactiveTickMarkColor: Colors.amber,
),
child: GetBuilder<CartoonReadLogic>(
id: 'slider',
builder: (_) => Slider(
min: 0,
max: 1,
value: logic.readScale,
onChanged: logic.updateReadPicIndex,
onChangeEnd: logic.onSeekEnd,
),
),
),
),
13.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () => logic.loadOtherEpisode(true),
child: Column(
children: [
Transform.rotate(
angle: pi,
child: Image.asset('common_back_new.png'.commonImgPath,
width: 24),
),
4.sizeBoxH,
Text('下一篇',
style: textStyle(12, Colors.white, FontWeight.w400)),
],
),
),
],
),
);
}
// 喜欢按钮
Widget _buildLiked(CartoonReadLogic logic) {
return GetBuilder<CartoonReadLogic>(
id: 'like',
builder: (_) => AnimatedPositioned(
duration: Duration(milliseconds: logic.animationTime),
bottom: 160,
right: logic.showMenu ? 0 : -120,
child: Consumer<ACGSourceManager>(
builder: (_, manager, __) {
final liked = manager.mediaInfo?.mediaStatus?.hasLiked == true;
return InkWell(
enableFeedback: false,
onTap: manager.onLikeAction,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: .5),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(999),
bottomLeft: Radius.circular(999),
),
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
liked
? 'acg_like_sel.png'.acgImgPath
: 'acg_like_nor.png'.acgImgPath,
width: 24,
color: liked ? null : Colors.white,
),
5.sizeBoxW,
Text('喜欢',
style: textStyle(12, Colors.white, FontWeight.w400)),
],
),
),
);
},
),
),
);
}
// 自动播放按钮
Widget _buildAutoPlay(CartoonReadLogic logic) {
return Positioned(
bottom: 30,
child: GetBuilder<CartoonReadLogic>(
id: 'autoplay',
builder: (_) => AnimatedScale(
scale: logic.autoPlay ? 1 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.linear,
child: IgnorePointer(
ignoring: !logic.autoPlay,
child: GestureDetector(
onTap: () => logic.onChangeAutoPlay(false),
child: Container(
height: 56,
width: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
color: Colors.white,
),
child: Center(
child: Image.asset('cartoon_auto_play.gif'.acgImgPath,
width: 24),
),
),
),
),
),
),
);
}
// 章节抽屉
Widget _buildDrawer(CartoonReadLogic logic) {
return GetBuilder<CartoonReadLogic>(
id: 'menu',
builder: (_) => Container(
color: AppColors.primaryColor,
width: 210,
height: screen.screenHeight,
child: Column(
children: [
80.sizeBoxH,
Row(
children: [
16.sizeBoxW,
Expanded(
child: Text(
logic.manager.mediaInfo?.title ?? '',
style: TextStyle(
fontSize: 20,
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w900,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
18.sizeBoxH,
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
color: AppColors.actionRed,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${logic.manager.mediaInfo?.totalEpisode}${logic.manager.mediaInfo?.getDetailUpdateString ?? ''}',
style: const TextStyle(
fontSize: 14,
color: Color(0xff141414),
fontWeight: FontWeight.w400),
),
InkWell(
enableFeedback: false,
onTap: logic.toggleSort,
child: Row(
children: [
AnimatedRotation(
turns: logic.sortType ? 0 : 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
child: Image.asset('text_play_sort.png'.acgImgPath,
color: const Color(0xff141414),
width: 16,
height: 16),
),
4.sizeBoxW,
Text(
logic.sortType ? '正序' : '倒序',
style: const TextStyle(
fontSize: 14,
color: Color(0xff141414),
fontWeight: FontWeight.w400),
),
],
),
),
],
),
),
Expanded(
child: ListView.builder(
key: ValueKey(logic.sortType),
itemCount: logic.manager.allEpisodes.length,
padding: const EdgeInsets.only(top: 6, bottom: 20),
itemBuilder: (_, index) {
// 不使用 list.reverseitems 不够会从底部排),采用数据倒序方法
final info = logic.sortType
? logic.manager.allEpisodes[index]
: logic.manager.allEpisodes[
logic.manager.allEpisodes.length - index - 1];
return StaggerInItem(
index: index,
child: GestureDetector(
onTap: () {
Get.back();
if (logic.detailInfo?.id == info.id)
return; //点的就是当前章,不重复加载
logic.loadDataWithIndex(info.id ?? '');
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
height: 40,
child: Row(
children: [
_buildDrawerItem(
//与详情/弹窗子集 title 格式一致:第X话(漫画不带 name)
'${info.episodeNumber ?? ''}${logic.manager.mediaInfo?.unit ?? ''}',
logic.detailInfo?.id == info.id,
),
],
),
),
),
);
},
),
),
],
),
),
);
}
Widget _buildDrawerItem(String title, bool select) {
return Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 16.sp,
color: select
? AppColors.actionRed
: Colors.white.withValues(alpha: 0.55),
fontWeight: select ? FontWeight.w500 : FontWeight.w400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
}
}
@@ -0,0 +1,45 @@
//小说/视频推荐
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
import '../../hj_utils/api_service/common_service.dart';
class CartoonRecommendLogic extends ListBaseLogic<dynamic> {
final String? mediaId; //acg 推荐的 tagId
final MediaStyle mediaStyle; //推荐类型:Video(真人视频) / Cartoon(动漫) / Comics(漫画)
final String? videoTagId; //视频(SP)推荐的 tagId,按当前视频 tag 拉同类
CartoonRecommendLogic(
{this.mediaId, this.mediaStyle = MediaStyle.Comics, this.videoTagId});
@override
void onReady() {
super.onReady();
loadData();
}
//isRefresh 下拉刷新/加载更多;showLoading 切换时先清空显示 loading
void loadData({bool isRefresh = true, bool showLoading = false}) {
if (showLoading) {
dataList = null;
update();
}
fetchData(isRefresh: isRefresh, fetch: _fetch);
}
//按类型分流:视频走 getRecommendList,漫画/动漫走 comicsRecommendList
Future<(List<dynamic>?, bool)> _fetch(int page) async {
if (mediaStyle == MediaStyle.Video) {
final resp = await CommonService.getRecommendList(
pageNumber: page,
pageSize: 12,
tagId: videoTagId,
newsType: mediaStyle.rankParam);
return (resp?.videos, resp?.hasNext == true);
}
final model = await ACGService.comicsRecommendList(page, 12,
tagId: mediaId, mediaType: mediaStyle.rankParam);
return (model?.list, model?.hasNext == true);
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/cartoon_media_info.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import '../../hj_model/video_model.dart';
import '../home/home_cell_style/video_simple_cell.dart';
import 'acg_widget_item.dart';
import 'cartoon_recommend_logic.dart';
class CartoonRecommendPage extends StatefulWidget {
final String? mediaId;
final String? videoTagId; //视频(SP)推荐按当前视频 tag 拉同类
final EdgeInsetsGeometry? padding;
final Function()? tapAction;
final MediaStyle mediaStyle; //推荐类型:Video(真人视频) / Cartoon(动漫) / Comics(漫画)
final int crossAxisCount; //网格列数
final double childAspectRatio; //网格宽高比
final Function(VideoModel model)? onVideoTap; //视频 cell 点击:不传则走默认 push
final Function(CartoonMediaInfo model)? onAcgTap; //acg cell 点击:不传则走默认 push
const CartoonRecommendPage({
super.key,
this.mediaId,
this.tapAction,
this.padding = const EdgeInsets.only(left: 16, right: 16, top: 10),
this.mediaStyle = MediaStyle.Comics,
this.videoTagId,
this.crossAxisCount = 3,
this.childAspectRatio = 111 / 188,
this.onVideoTap,
this.onAcgTap,
});
@override
State<CartoonRecommendPage> createState() => _CartoonRecommendPageState();
}
class _CartoonRecommendPageState extends State<CartoonRecommendPage>
with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<CartoonRecommendLogic>(
tag: uniqueTag,
init: CartoonRecommendLogic(
mediaId: widget.mediaId,
mediaStyle: widget.mediaStyle,
videoTagId: widget.videoTagId,
),
builder: (logic) {
if (logic.isLoading) return LoadingCenterWidget();
if (logic.isEmptyData)
return CErrorWidget(retryOnTap: () => logic.loadData());
final list = logic.dataList!;
return pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onLoading: (ctr) => logic.loadData(isRefresh: false),
enablePullDown: false,
child: GridView.builder(
physics: const ClampingScrollPhysics(),
padding: widget.padding,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: widget.childAspectRatio,
),
itemCount: list.length,
itemBuilder: (_, index) => widget.mediaStyle == MediaStyle.Video
? _videoCell(list[index])
: _acgCell(list[index]),
),
);
},
);
}
// 视频推荐 cell:不传 onVideoTap 走 cell 默认 push,详情页传入则当前页切播放源
Widget _videoCell(VideoModel model) {
return VideoSimpleCell(
videoModel: model,
onTap: widget.onVideoTap == null ? null : () => widget.onVideoTap!(model),
);
}
// 漫画/动漫推荐 cell:不传 onAcgTap 时默认跳详情页
Widget _acgCell(CartoonMediaInfo model) {
return AcgItemWidget(
info: model,
tapCallback: widget.onAcgTap == null
? () {
widget.tapAction?.call();
pushToCartoonPage(model);
}
: () => widget.onAcgTap!(model),
);
}
}
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'cartoon_sectionlist_sub_page.dart';
//漫画专题列表的排序 tab(标题与排序值绑定)
const _sortTabs = [
SortTab('最多收藏', 3),
SortTab('最新上架', 1),
SortTab('最多观看', 2),
];
//专题更多
class CartoonSectionListPage extends StatefulWidget {
final String? sectionID;
final String? tagName;
const CartoonSectionListPage({
super.key,
this.sectionID,
this.tagName,
});
@override
State<CartoonSectionListPage> createState() => _CartoonSectionListPageState();
}
class _CartoonSectionListPageState extends State<CartoonSectionListPage>
with SingleTickerProviderStateMixin {
late final TabController tabCtr =
TabController(length: _sortTabs.length, vsync: this);
@override
void dispose() {
tabCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.tagName ?? '')),
body: Column(
children: [
_buildTabs(),
Expanded(
child: TabBarView(
controller: tabCtr,
children: List.generate(
_sortTabs.length,
(i) => CartoonSectionSubPage(
sortType: _sortTabs[i].sort,
sectionID: widget.sectionID,
).keepAlive,
),
),
)
],
),
);
}
Widget _buildTabs() {
return Container(
padding: const EdgeInsets.only(bottom: 3),
alignment: Alignment.center,
child: TabBar(
tabAlignment: TabAlignment.center,
controller: tabCtr,
tabs: List.generate(
_sortTabs.length,
(index) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: index != _sortTabs.length - 1
? Border(
right: BorderSide(
width: .5, color: Colors.white.withValues(alpha: .1)),
)
: null,
),
child: Text(_sortTabs[index].name),
),
),
labelColor: Colors.white.withValues(alpha: .9),
labelPadding: EdgeInsets.zero,
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
unselectedLabelColor: Colors.white.withValues(alpha: 0.5),
unselectedLabelStyle:
const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
indicator: const BoxDecoration(),
),
);
}
}
@@ -0,0 +1,31 @@
import 'package:hgdj/hj_model/cartoon_media_info.dart';
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
class CartoonSectionSubLogic extends ListBaseLogic<CartoonMediaInfo> {
final int? sortType;
final String? sectionID;
CartoonSectionSubLogic({this.sortType, this.sectionID});
@override
void onReady() {
super.onReady();
loadData();
}
void loadData() => fetchData(isRefresh: true, fetch: _fetch);
void loadMoreData() => fetchData(isRefresh: false, fetch: _fetch);
Future<(List<CartoonMediaInfo>?, bool)> _fetch(int page) async {
final resp = await ACGService.getPreferences(
page,
12,
sortType: sortType,
sId: sectionID ?? '',
type: 0,
);
return (resp?.list, resp?.hasNext == true);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'acg_widget_item.dart';
import 'cartoon_sectionlist_sub_logic.dart';
class CartoonSectionSubPage extends StatelessWidget {
final int? sortType;
final String? sectionID;
const CartoonSectionSubPage({super.key, this.sectionID, this.sortType});
//按排序值隔离三个 tab 的多实例
String get _tag => (sortType ?? 0).toString();
@override
Widget build(BuildContext context) {
return GetBuilder<CartoonSectionSubLogic>(
tag: _tag,
init: CartoonSectionSubLogic(sortType: sortType, sectionID: sectionID),
builder: (logic) {
if (logic.isLoading) return LoadingCenterWidget();
return pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onRefresh: (ctr) => logic.loadData(),
onLoading: (ctr) => logic.loadMoreData(),
child: logic.isEmptyData ? CErrorWidget() : _buildGrid(logic),
);
},
);
}
Widget _buildGrid(CartoonSectionSubLogic logic) {
return GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 5,
childAspectRatio: 111 / 190,
),
itemCount: logic.dataList?.length ?? 0,
itemBuilder: (context, index) =>
AcgItemWidget(info: logic.dataList?[index]),
);
}
}
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/home/plate_model.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'cartoon_sub_grid_page.dart';
class CartoonSubDetailPage extends StatelessWidget {
final ModuleData? tagData;
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
final ScrollController? scroll;
const CartoonSubDetailPage({
super.key,
this.tagData,
this.type = MediaStyle.Pic,
this.scroll,
});
@override
Widget build(BuildContext context) {
return CartoonSubGridPage(tagData: tagData, type: type, scrollCtr: scroll)
.keepAlive;
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/home/module_detail_model.dart';
import 'package:hgdj/hj_model/home/plate_model.dart';
import 'package:hgdj/hj_utils/api_service/vid_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
class CartoonSubGridLogic extends ListBaseLogic {
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
final ModuleData? tagData; //模块model
List<SortTab<int>> sortTabs = []; //排序 tab:标题与 sort 值绑定
List<AllSection> specials = []; //动漫和漫画 添加标签
late TabController tabCtr;
int get crossAxisCount {
if (type == MediaStyle.Cartoon) return 3;
if (type == MediaStyle.Pic) return 3;
if (type == MediaStyle.Game) return 2;
if (type == MediaStyle.Novel) return 3;
return 0;
}
double get childAspectRatio {
if (type == MediaStyle.Cartoon) return 111 / 190;
if (type == MediaStyle.Pic) return 168 / 246;
if (type == MediaStyle.Game) return 168 / 137;
if (type == MediaStyle.Novel) return 111 / 190;
return 1;
}
String get newsType {
if (tagData?.moduleName == '热门推荐' && tagData?.type == -100) return 'PIC';
if (tagData?.moduleName == '热门推荐' && tagData?.type == -101)
return 'SEED_LINK';
return '';
}
bool get isAcg =>
type == MediaStyle.Novel || type == MediaStyle.Cartoon; //是否是acg模块
CartoonSubGridLogic({this.tagData, this.type = MediaStyle.Pic});
@override
void onClose() {
tabCtr.dispose();
super.onClose();
}
@override
onInit() {
super.onInit();
if (type == MediaStyle.Cartoon) {
//动漫:排序 tab 可能来自后端,没配就用这套默认
sortTabs = tagData?.sortTabs ??
const [
SortTab('热门推荐', 2),
SortTab('最新上架', 1),
SortTab('最新热评', 9),
SortTab('最多收藏', 7),
];
} else if (type == MediaStyle.Pic || type == MediaStyle.Novel) {
sortTabs = const [
SortTab('最多观看', 3),
SortTab('最新发布', 1),
SortTab('最多收藏', 7)
];
} else {
sortTabs = const [SortTab('最新', 1), SortTab('最热', 2), SortTab('畅销', 8)];
}
tabCtr = TabController(length: sortTabs.length, vsync: this);
}
@override
onReady() {
super.onReady();
loadData();
}
//获取海角样式数据
loadData({int pageNum = 1}) async {
dataList ??= [];
ModuleDetailModel? retModel;
if (tagData?.moduleName == '热门推荐' &&
(tagData?.type == -100 || tagData?.type == -101)) {
//图集
retModel = await VidService.communityRecommend(
pageNum,
pageSize: type == MediaStyle.Pic ? 12 : 10, //色图一行3个,按12加载凑整4行;黄游保持10
newsType: newsType,
sortType: sortTabs[tabCtr.index].sort,
);
if (retModel != null) {
if (pageNum == 1) dataList?.clear();
currentPage = pageNum;
dataList?.addAll(retModel.allVideoInfo ?? []); //设置海角样式列表
}
} else {
retModel = await VidService.getModuleDetail(
tagData?.id ?? "",
pageNumber: pageNum,
pageSize: 12,
moduleSort: sortTabs[tabCtr.index].sort,
);
if (retModel != null) {
currentPage = pageNum;
if (pageNum == 1) {
dataList?.clear();
specials.clear();
specials.addAll(retModel.allSection ?? []);
}
if (isAcg) {
dataList?.addAll(retModel.allMediaInfo ?? []); //设置海角样式列表
} else {
dataList?.addAll(retModel.allVideoInfo ?? []); //设置海角样式列表
}
}
}
update();
refreshCtr?.refreshCompleted();
retModel?.hasNext == true
? refreshCtr?.loadComplete()
: refreshCtr?.loadNoData();
}
loadMoreData() => loadData(pageNum: currentPage + 1);
//切换排序 tab:先清空列表显示 loading,延迟一帧再按新 sort 拉第一页(避免请求卡住切换动画)
void onChangeIndexAction() {
dataList = null;
update();
Future.delayed(const Duration(milliseconds: 50), () => loadData());
}
}
@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_model/home/plate_model.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/sliver_delegate.dart';
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import '../home/home_cell_style/quick_entry_row.dart';
import '../home/home_cell_style/video_simple_cell.dart';
import '../home/home_sub_module/widget/special_topics_view.dart';
import 'acg_widget_item.dart';
import 'cartoon_sub_grid_logic.dart';
import 'photo_gallery_item.dart';
//海角样式:漫画/图集/黄游/小说共用的网格列表,item 样式与列数按 [type] 切
class CartoonSubGridPage extends StatelessWidget {
final ModuleData? tagData; //模块model
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
final ScrollController? scrollCtr;
const CartoonSubGridPage({
super.key,
this.tagData,
this.type = MediaStyle.Pic,
this.scrollCtr,
});
// 多 type/模块 tab 经 keepAlive 并存,按 id+type 隔离各自的 logic
String get _tag => '${tagData?.id ?? ''}$type';
@override
Widget build(BuildContext context) {
return GetBuilder<CartoonSubGridLogic>(
tag: _tag,
init: CartoonSubGridLogic(tagData: tagData, type: type),
builder: (logic) => pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onRefresh: (ctr) => logic.loadData(),
onLoading: (ctr) => logic.loadMoreData(),
child: _buildContent(logic),
),
);
}
Widget _buildContent(CartoonSubGridLogic logic) {
return CustomScrollView(
controller: scrollCtr,
slivers: [
if (tagData?.pureVersion != true)
SliverToBoxAdapter(
child: AdsGridViewWidget(
type == MediaStyle.Cartoon ? 4 : 7, //广告位标识
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
accordingAdsType: true,
),
),
// 这两块只有动漫/漫画有
if (type == MediaStyle.Cartoon) ...[
SliverToBoxAdapter(child: QuickEntryRow(tagData)),
SliverToBoxAdapter(
child: SpecialTopicsView(
logic.specials,
module: tagData,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
),
),
],
_buildSortType(logic),
_buildGridList(logic),
],
);
}
//排序
Widget _buildSortType(CartoonSubGridLogic logic) {
return SliverPersistentHeader(
pinned: true,
delegate: MySliverDelegate(
maxHeight: 36,
minHeight: 36,
child: Container(
alignment: Alignment.center,
color: AppColors.primaryColor,
child: TabBar(
tabAlignment: TabAlignment.center,
controller: logic.tabCtr,
isScrollable: true,
//选中/未选中只差颜色,unselectedLabelStyle 不传会 fallback 到 labelStyle
labelStyle:
const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
labelColor: Colors.white.withValues(alpha: .9),
unselectedLabelColor: Colors.white.withValues(alpha: .55),
indicator: const BoxDecoration(), //去掉默认下划线
onTap: (_) => logic.onChangeIndexAction(),
tabs: logic.sortTabs
.map((e) => Padding(
padding: const EdgeInsets.only(top: 12, bottom: 4),
child: Text(e.name),
))
.toList(),
),
),
),
);
}
Widget _buildGridList(CartoonSubGridLogic logic) {
final list = logic.dataList;
//null 是还没拉过,空 list 才是真没数据
if (list == null) {
return const SliverToBoxAdapter(
child: SizedBox(height: 400, child: LoadingCenterWidget()));
}
if (list.isEmpty) {
return SliverToBoxAdapter(
child: SizedBox(
height: 400, child: CErrorWidget(retryOnTap: logic.loadData)),
);
}
return SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: logic.crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 5,
childAspectRatio: logic.childAspectRatio,
),
delegate: SliverChildBuilderDelegate(
//-1动漫/漫画 0-图集 1-黄游 2-小说
(_, index) => switch (type) {
MediaStyle.Cartoon ||
MediaStyle.Novel =>
AcgItemWidget(info: list[index], coverV: true),
MediaStyle.Pic =>
PhotoGalleryItem(videoModel: list[index], textline: 1),
MediaStyle.Game => VideoSimpleCell(
videoModel: list[index],
isFromHY: true,
textLines: 1,
),
_ => const SizedBox.shrink(),
},
childCount: list.length,
),
),
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_model/home/plate_model.dart';
import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart';
import 'package:hgdj/hj_utils/const.dart';
class CartoonSubModuleLogic extends GetxController
with GetTickerProviderStateMixin {
final int tabIndex; // 未初始化时,内链跳转需要
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
List<ModuleData> tabs = []; //漫画
late TabController tabController;
CartoonSubModuleLogic(this.tabIndex, {this.type = MediaStyle.Pic});
@override
void onInit() {
if (type == MediaStyle.Pic) {
//0-图集 showtype = 2,列表
tabs.addAll(Config.plateModule?.picsUi ?? []);
} else if (type == MediaStyle.Game) {
//1-黄游 showtype = 2,列表
tabs.addAll(Config.plateModule?.gameUi ?? []);
} else if (type == MediaStyle.Novel) {
//2-小说
tabs.addAll(Config.plateModule?.novel ?? []);
}
tabController = TabController(
initialIndex: tabIndex,
length: tabs.length,
vsync: this,
);
tabController.addListener(() {
if (tabController.indexIsChanging) return;
CommunityBottomProvider().subIndex = tabController.index;
});
super.onInit();
}
@override
void onClose() {
tabController
.dispose(); // 自建 TabController,随 logic 释放(含内部 AnimationController + 匿名监听)
super.onClose();
}
void skipToTabIndex(int index) {
tabController.index = index;
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import 'cartoon_sub_detail_page.dart';
import 'cartoon_sub_module_logic.dart';
//漫画亚模块
class CartoonSubModulePage extends StatelessWidget {
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
final int tabIndex;
const CartoonSubModulePage(
{super.key, this.type = MediaStyle.Pic, this.tabIndex = 0});
@override
Widget build(BuildContext context) {
return GetBuilder<CartoonSubModuleLogic>(
init: CartoonSubModuleLogic(tabIndex, type: type),
tag: type.name,
builder: (logic) => Column(
children: [
_buildTabbar(logic),
Expanded(
child: TabBarView(
controller: logic.tabController,
children: List.generate(
logic.tabs.length,
(index) => CartoonSubDetailPage(
tagData: logic.tabs[index],
type: type,
scroll: CommunityBottomProvider().scrollCtr(
type == MediaStyle.Pic
? MediaStyle.Pic
: MediaStyle.Novel,
index),
),
),
),
)
],
),
);
}
Widget _buildTabbar(CartoonSubModuleLogic logic) {
return TabBar(
controller: logic.tabController,
padding: const EdgeInsets.symmetric(horizontal: 8),
tabs: List.generate(
logic.tabs.length,
(index) => Padding(
padding: const EdgeInsets.only(top: 6, bottom: 8),
child: Text(logic.tabs[index].moduleName ?? ''),
),
),
labelPadding: const EdgeInsets.symmetric(horizontal: 8),
tabAlignment: TabAlignment.start,
isScrollable: true,
labelColor: Colors.white.withValues(alpha: .9),
labelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
unselectedLabelColor: Colors.white.withValues(alpha: .55),
unselectedLabelStyle:
const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
indicator: CustomIndicator(offsetY: 6),
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../hj_model/cartoon_media_info.dart';
import '../../hj_utils/api_service/acg_service.dart';
import '../../hj_utils/history_util.dart';
import '../../routers/jump_router.dart';
import 'audio_player_bottomsheet.dart';
import 'text_novel_read_page.dart';
import 'voice_novel_read_page.dart';
import 'widget/acg_source_manager.dart';
class NovelDetailLogic extends GetxController {
String id;
CartoonMediaInfo? model;
final recommends = [];
int page = 1;
RefreshController? refreshCtr;
bool showHeader = true;
double showOffset = 100;
late ACGSourceManager manager;
late ScrollController scrollCtr = ScrollController()
..addListener(() {
if (scrollCtr.offset <= showOffset) {
if (!showHeader) {
showHeader = true;
update(['header']);
}
} else {
if (showHeader) {
showHeader = false;
update(['header']);
}
}
});
NovelDetailLogic(this.id) : manager = ACGSourceManager(id);
@override
void onReady() async {
super.onReady();
// 先拿详情(含 freeEpisode),再拉子集,才能正确标记前 N 集免费
await fetchDetailData();
update();
manager.fetchAllEpisodes();
fetchRecommendData();
}
@override
void onClose() {
scrollCtr.dispose(); //修复泄漏
// manager 由页面的 ChangeNotifierProvider(create:) 在卸载时 dispose,本类不再重复释放
// refreshCtr 由 CustomRefreshView 创建并 dispose,本类只持引用,绝不能再 dispose
super.onClose();
}
Future fetchDetailData() async {
final result = await ACGService.getMediaInfo(id);
if (result != null) {
model = result;
manager.mediaInfo = result;
HistoryUtil.insert(model!, MediaStyle.Novel);
} else {
model = CartoonMediaInfo();
}
}
// 获取相关推荐
Future fetchRecommendData() async {
final result = await ACGService.comicsRecommendList(page, 12,
tagId: model?.tagDetails?.firstOrNull?.id ?? '', mediaType: 'text');
if (result != null) {
page += 1;
recommends.addAll(result.list ?? []);
}
result?.hasNext ?? false
? refreshCtr?.loadComplete()
: refreshCtr?.loadNoData();
update();
}
// 打开所有章节
episodesBottomSheet() async {
final result = await Get.bottomSheet(AudioPlayerBottomSheet(
model?.title ?? '',
allAudiobooks: manager.allEpisodes,
currentPlayIndex: manager.index,
unit: model?.unit,
hasPermission: model?.hasPermission ?? false,
));
if (result != null) {
gotoPlay(index: result as int);
}
}
gotoPlay({int index = 0}) async {
final mediaSubType = model?.mediaSubType ?? -1;
final opm = await manager
.getEpisodeHasPermisson(index); //通过时内部已切集,不必再 changeEpisodesIndex
if (opm != null) {
//0-默认文本小说 1-有声小说
if (mediaSubType == 0) {
Get.to(() => TextNovelReadPage(manager: manager),
preventDuplicates: false);
} else if (mediaSubType == 1) {
Get.to(
() => VoiceNovelReadPage(
manager: manager,
id: id,
index: index,
title: model?.title ?? '',
),
opaque: false,
preventDuplicates: false);
}
}
}
// 跳转
jumpToOtherPage(CartoonMediaInfo info) {
if (model?.id == info.id) {
showToast('您当前正在观看此媒体');
return;
}
pushToCartoonPage(info);
}
}
+293
View File
@@ -0,0 +1,293 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:hgdj/tools_base/widget/follow_button.dart';
import 'package:provider/provider.dart';
import '../../hj_utils/widget_util.dart';
import '../../tools_base/loading/loading_center_widget.dart';
import '../../tools_base/refresh/pull_refresh.dart';
import 'acg_widget_item.dart';
import 'novel_detail_logic.dart';
import 'novel_header.dart';
import 'widget/acg_source_manager.dart';
import 'widget/free_badge.dart';
//小说详情页 -
class NovelDetailPage extends StatefulWidget {
final String? id;
const NovelDetailPage(this.id, {super.key});
@override
State<NovelDetailPage> createState() => _NovelDetailPageState();
}
// 详情页可经「推荐 → 再开详情页」成环并存,用 UniqueTagMixin 给每个实例唯一 tag
class _NovelDetailPageState extends State<NovelDetailPage> with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<NovelDetailLogic>(
tag: uniqueTag,
init: NovelDetailLogic(widget.id ?? ''),
builder: (_) => Scaffold(
body: () {
if (_.model == null) return LoadingCenterWidget();
if (_.model?.id == null) return CErrorWidget();
// 用 create: 让 Provider 在页面卸载时自动 dispose manager(释放音频播放器+订阅),Flutter 层保证释放
return ChangeNotifierProvider<ACGSourceManager>(
create: (ctx) => _.manager,
child: _buildContent(_),
);
}(),
),
);
}
_buildContent(NovelDetailLogic _) {
return Stack(
alignment: Alignment.center,
children: [
Column(
children: [
Expanded(
child: pullYsRefresh(
onInit: (ctr) => _.refreshCtr = ctr,
enablePullDown: false,
onLoading: (ctr) => _.fetchRecommendData(),
child: CustomScrollView(
controller: _.scrollCtr,
slivers: [
SliverToBoxAdapter(child: _buildNovelHeader(_)),
18.sliverSizeBoxH,
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'相关推荐',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
12.sliverSizeBoxH,
if (_.recommends.isNotEmpty) ...[
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 16),
sliver: SliverPadding(
padding: EdgeInsets.only(bottom: 80),
sliver: SliverGrid.builder(
itemCount: _.recommends.length,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 115 / 192,
),
itemBuilder: (ctx, index) {
final model = _.recommends[index];
return AcgItemWidget(
info: _.recommends[index],
tapCallback: () => _.jumpToOtherPage(model),
);
}),
),
),
] else ...[
SliverToBoxAdapter(
child: CErrorWidget(),
)
],
50.sliverSizeBoxH
],
),
),
),
InkWell(
enableFeedback: false,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
child: Consumer<ACGSourceManager>(
builder: (context, value, child) => Text(
_.manager.actionTitle(),
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
)),
onTap: () => _.gotoPlay(index: _.manager.index),
),
],
),
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildTopHeader(_),
)
],
);
}
_buildTopHeader(NovelDetailLogic _) {
return GetBuilder<NovelDetailLogic>(
tag: uniqueTag,
id: 'header',
builder: (_) {
return Container(
padding: EdgeInsets.only(left: 16, right: 16, top: screen.paddingTop),
height: 44 + screen.paddingTop,
color: !_.showHeader ? Color(0xff0E141E) : Colors.transparent,
child: Row(
children: [
InkWell(
enableFeedback: false,
child: Image.asset('acg_noval_back.png'.acgImgPath, width: 24),
onTap: () => Get.back(),
),
40.sizeBoxW,
Spacer(),
Text(
!_.showHeader ? _.model?.title ?? '' : '',
style: textStyle(16, Colors.white, FontWeight.w500),
),
Spacer(),
Consumer<ACGSourceManager>(
builder: (context, manager, child) {
bool col =
manager.mediaInfo?.mediaStatus?.hasCollected ?? false;
return FollowButton(
mediaId: manager.mediaInfo?.id ?? '',
isFollow: col,
followType: FollowEnum.noval,
successsAction: (isSuccess) {
manager.updateCollectState(isSuccess);
if (isSuccess) {
showToast('收藏成功');
}
},
);
},
)
],
),
);
},
);
}
_buildNovelHeader(NovelDetailLogic _) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NovelHeader(manager: _.manager),
18.sizeBoxH,
Consumer<ACGSourceManager>(
builder: (ctx, manager, child) => Column(
children: [
18.sizeBoxH,
Row(
children: [
Text(
'${_.model?.totalEpisode ?? 0}${_.model?.unit}',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
Spacer(),
InkWell(
enableFeedback: false,
onTap: () => _.episodesBottomSheet(),
child: Row(
children: [
Text(
'目录',
style: TextStyle(
color: Color(0xffA7A7A7),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
2.sizeBoxW,
Image.asset(
'arrow_right_grey.webp'.commonImgPath,
color: Color(0xff989898),
width: 18,
)
],
)),
],
),
6.sizeBoxH,
ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: NeverScrollableScrollPhysics(),
itemCount: min(manager.allEpisodes.length, 4),
separatorBuilder: (context, index) => Divider(
height: .5,
color: Colors.white.withValues(alpha: .05),
),
itemBuilder: (BuildContext context, int index) {
final epm = manager.allEpisodes[index];
return InkWell(
enableFeedback: false,
onTap: () => _.gotoPlay(index: index),
child: Column(
children: [
12.sizeBoxH,
Row(
children: [
Text(
'${epm.episodeNumber ?? 1}${_.model?.unit}',
style: TextStyle(
color: manager.index == index
? AppColors.actionRed
: Color(0xff989898),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
//无任何权限时,前 N 集免费展示「免费」角标
if (manager.mediaInfo?.hasPermission == false &&
epm.inFreeEpisode) ...[
6.sizeBoxW,
const FreeBadge(),
],
Spacer(),
Icon(Icons.navigate_next_sharp,
size: 20, color: Colors.white)
],
),
12.sizeBoxH,
],
),
);
},
),
],
),
).paddingSymmetric(horizontal: 16),
],
);
}
}
+146
View File
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../hj_model/cartoon_media_info.dart';
import '../../hj_model/video_model.dart';
import '../../hj_utils/widget_util.dart';
import '../../tools_base/widget/shrink_wrap.dart';
import '../home/tag/cartoon_tag_page.dart';
import 'widget/acg_expand_text.dart';
import 'widget/acg_source_manager.dart';
class NovelHeader extends StatelessWidget {
final ACGSourceManager? manager;
const NovelHeader({super.key, this.manager});
@override
Widget build(BuildContext context) {
final info = manager?.mediaInfo;
return Stack(
children: [
Positioned(
left: 0,
top: 0,
right: 0,
child: NetworkImageLoader(imageUrl: info?.coverH ?? ''),
),
Positioned.fill(
child: Container(color: Colors.black.withValues(alpha: .7)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
(screen.paddingTop + 72).sizeBoxH,
_titleRow(info),
18.sizeBoxH,
if (info?.summary?.isNotEmpty == true) ...[
12.sizeBoxH,
AcgShowMoreTextWidget(
summary: info?.summary ?? '',
maxWidth: Get.width - 32,
),
],
20.sizeBoxH,
],
),
),
],
);
}
Widget _titleRow(CartoonMediaInfo? info) {
return Row(
children: [
Stack(
children: [
NetworkImageLoader(
imageUrl: info?.coverH ?? '',
width: 111,
height: 148,
),
if (info?.isAudiobooks == true)
Positioned(
top: 6,
left: 6,
child: Image.asset('noval_sign.png'.acgImgPath, width: 20),
),
],
),
12.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info?.title ?? '',
style: textStyle(16, Colors.white, FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
6.sizeBoxH,
Row(
children: [
Text(
'${info?.totalEpisode ?? 0}${info?.unit}',
style:
textStyle(12, const Color(0xff999999), FontWeight.w400),
),
8.sizeBoxW,
Text(
'/',
style:
textStyle(12, const Color(0xff999999), FontWeight.w400),
),
8.sizeBoxW,
Text(
info?.updateDesc ?? '',
style: textStyle(
12, info!.getDetailUpdateColor, FontWeight.w400),
),
],
),
if (info.tagDetails?.isNotEmpty == true) ...[
6.sizeBoxH,
_tags(info.tagDetails!),
],
],
),
),
],
);
}
Widget _tags(List<TagsBean> tags) {
return ShrinkWrap(
spacing: 10,
runSpacing: 10,
maxLines: 1,
children: tags
.map((tag) => InkWell(
enableFeedback: false,
onTap: () => Get.to(
CartoonTagPage(title: tag.name ?? '', sId: tag.id ?? '')),
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xff22252D),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'#${tag.name ?? ''}',
maxLines: 1,
style:
const TextStyle(color: Color(0xFF999999), fontSize: 12),
),
),
))
.toList(),
);
}
}
+109
View File
@@ -0,0 +1,109 @@
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/hj_page/find/pic_collect/pics_detail_page.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
//图集item
class PhotoGalleryItem extends StatelessWidget {
final VideoModel? videoModel;
final Function? callback;
final int textline;
const PhotoGalleryItem({
super.key,
this.videoModel,
this.callback,
this.textline = 1,
});
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
onTap: () {
if (callback != null) {
callback?.call();
} else {
Get.to(PicsDetailPage(id: videoModel?.id), preventDuplicates: false);
}
},
child: Column(
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(
imageUrl: videoModel?.cover ?? "",
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: .6),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)),
height: 30,
child: Row(
children: [
8.sizeBoxW,
Image.asset("eye_white.webp".commonImgPath, width: 16),
SizedBox(width: 2),
Text(
videoModel?.playCount?.countStr ?? "",
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
Spacer(),
Image.asset("tuji_icon.webp".communityPath, width: 16),
2.sizeBoxW,
Text(
'${videoModel?.seriesCover?.length ?? 0}',
textAlign: TextAlign.justify,
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
8.sizeBoxW,
],
),
),
)
],
),
),
4.sizeBoxH,
Row(
children: [
Expanded(
child: Text(
'${videoModel?.title}',
maxLines: textline,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 12,
),
),
)
],
)
],
),
);
}
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/media_content.dart';
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../hj_model/acg/comic_chapters_model.dart';
import '../../hj_model/cartoon_media_info.dart';
import '../../tools_base/loading/loading_helper.dart';
import 'widget/acg_source_manager.dart';
// 文字小说阅读 logic
class TextNovelReadLogic extends GetxController {
final ACGSourceManager manager;
CartoonMediaInfo? mediaInfo;
MediaContent? detailInfo; // 当前子集详情
List<ComicChapterInfo> allEpisodes = [];
int currentEpisode = 0; // 当前子集数,从 0 开始
int episodeIndex = 1;
bool showMenu = true;
bool isDark = true;
bool isAscend = true; // 排序类型 true-正序 false-倒序
int durationTime = 250;
final ScrollController scrollCtr = ScrollController();
TextNovelReadLogic(this.manager);
@override
void onInit() {
super.onInit();
mediaInfo = manager.mediaInfo;
currentEpisode = manager.index;
}
@override
void onReady() {
super.onReady();
allEpisodes.addAll(manager.allEpisodes); // 复制,便于反转
loadData();
}
@override
void onClose() {
scrollCtr.dispose();
super.onClose();
}
// ========== 公开方法 ==========
// 获取文字小说。返回 Future 不是可有可无:CErrorWidget 靠它做重试 loading 和防连点
Future<void> loadData(
{bool showLoading = false, bool needToTop = false}) async {
if (showLoading) LoadingHelper.showLoading();
final model = await manager.getComicsMediaContent(index: currentEpisode);
if (showLoading) LoadingHelper.dismissLoading();
// 权限已在 getComicsMediaContent → getEpisodeHasPermisson 里判过(含前 N 集免费),直接展示
_setData(model);
update();
if (needToTop) _toTop();
}
// true 下一集 / false 上一集
void loadOtherEpisode(bool next, {bool showLoading = false}) async {
final index = next ? currentEpisode + 1 : currentEpisode - 1;
if (index < 0) {
showToast('这是第一${mediaInfo?.unit}了喔~');
return;
}
if (index > manager.allEpisodes.length - 1) {
showToast('这是最后一${mediaInfo?.unit}了喔~');
return;
}
final model = await manager.getComicsMediaContent(index: index);
if (model == null) return;
currentEpisode = index;
_setData(model);
update();
_toTop();
}
void loadDataWithIndex(String id) async {
// 目录列表可能倒序,按 id 定位 manager 中真实下标(不依赖"集号==下标+1",也天然不会越界)
final realIndex = manager.allEpisodes.indexWhere((e) => e.id == id);
if (realIndex < 0) {
showToast('数据错误,请联系客服');
return;
}
// 目录切集也要过权限:非免费/未解锁章节先弹 VIP/购买弹窗,通过后(内部已 changeEpisodesIndex)再拉详情
final epm = await manager.getEpisodeHasPermisson(realIndex);
if (epm == null) return;
LoadingHelper.showLoading();
final model =
await ACGService.getMediaDetail(mediaId: manager.mediaInfo?.id, id: id);
LoadingHelper.dismissLoading();
if (model != null) {
currentEpisode = realIndex;
_setData(model);
}
update();
}
// 展示菜单
void onShowMenuAction() {
showMenu = !showMenu;
update();
}
// 切换暗黑模式
void changeDarkStyle() {
isDark = !isDark;
update();
}
// 排序
void onChangeSortAction() {
isAscend = !isAscend;
allEpisodes = allEpisodes.reversed.toList();
update(['list', 'sort']);
}
// ========== 私有方法 ==========
void _setData(MediaContent? model) {
if (model != null) {
detailInfo = model;
manager.changeEpisodesIndex((model.episodeNumber ?? 1) - 1);
update();
} else {
detailInfo = MediaContent();
}
}
// 回到顶部
void _toTop() {
Future.delayed(const Duration(milliseconds: 100)).then((_) {
scrollCtr.jumpTo(0);
});
}
}
@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
import '../../tools_base/loading/loading_center_widget.dart';
import 'text_novel_read_logic.dart';
import 'widget/acg_source_manager.dart';
// 文字小说阅读页
class TextNovelReadPage extends StatelessWidget {
final ACGSourceManager manager;
const TextNovelReadPage({super.key, required this.manager});
@override
Widget build(BuildContext context) {
return GetBuilder<TextNovelReadLogic>(
init: TextNovelReadLogic(manager),
builder: (logic) => Scaffold(
backgroundColor: logic.isDark ? const Color(0xff0F0F0F) : Colors.white,
body: _buildBody(logic),
endDrawer: _buildEndDrawer(logic),
),
);
}
Widget _buildBody(TextNovelReadLogic logic) {
final detail = logic.detailInfo;
if (detail == null) return LoadingCenterWidget();
if (detail.id == null) return CErrorWidget(retryOnTap: logic.loadData);
//Builder 是为了拿到 Scaffold 之下的 context——header 的目录按钮要用它 openEndDrawer
return Builder(
builder: (ctx) => Stack(
fit: StackFit.expand,
children: [
_buildContent(logic),
_buildHeader(ctx, logic),
_buildFooter(logic),
],
),
);
}
// 正文,点击任意处切换菜单显隐
Widget _buildContent(TextNovelReadLogic logic) {
return InkWell(
enableFeedback: false,
onTap: logic.onShowMenuAction,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${logic.mediaInfo?.title ?? ''}/${logic.detailInfo?.name ?? ''}',
style: const TextStyle(fontSize: 16, color: Color(0xff525252)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
16.sizeBoxH,
Expanded(
child: SingleChildScrollView(
controller: logic.scrollCtr,
padding:
const EdgeInsets.only(bottom: 60), //给底部菜单栏留位,别被挡住最后几行
child: Align(
//正文不足一行时居中(长文自然撑满,看不出差别),与旧版 Column 默认的 center 保持一致
alignment: Alignment.topCenter,
child: Text(
logic.detailInfo?.text ?? '',
style: TextStyle(
fontSize: 16,
color: (logic.isDark ? Colors.white : Colors.black)
.withValues(alpha: .9),
),
),
),
),
),
],
),
),
),
);
}
// 顶部菜单栏,收起时整条移出屏幕上方
Widget _buildHeader(BuildContext ctx, TextNovelReadLogic logic) {
final height = 44 + screen.paddingTop;
final fg = logic.isDark ? Colors.white : Colors.black;
return AnimatedPositioned(
duration: Duration(milliseconds: logic.durationTime),
top: logic.showMenu ? 0 : -height,
left: 0,
right: 0,
child: Container(
height: height,
color: logic.isDark ? Colors.black : Colors.white,
padding:
EdgeInsets.only(top: screen.paddingTop + 5, left: 16, right: 16),
alignment: Alignment.topCenter, //按钮贴状态栏下沿,余量留在底部
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
enableFeedback: false,
onTap: Get.back,
child: Icon(Icons.arrow_back_ios, size: 24, color: fg),
),
Expanded(
child: Text(
logic.detailInfo?.name ?? '',
style: TextStyle(
fontSize: 18, color: fg, fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// 日/夜间模式
InkWell(
enableFeedback: false,
onTap: logic.changeDarkStyle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: Image.asset(
logic.isDark
? 'text_sun.png'.acgImgPath
: 'text_moon.png'.acgImgPath,
// key 按状态区分,AnimatedSwitcher 才会把它当成新 child 触发动画
key: ValueKey(logic.isDark),
width: 24,
),
),
),
),
// 目录
InkWell(
enableFeedback: false,
onTap: () => Scaffold.of(ctx).openEndDrawer(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7),
child: Image.asset('text_play_menu.png'.acgImgPath,
width: 24, color: fg),
),
),
],
),
),
);
}
// 底部翻章栏,收起时整条移出屏幕下方
Widget _buildFooter(TextNovelReadLogic logic) {
final style = TextStyle(
fontSize: 14, color: logic.isDark ? Colors.white : Colors.black);
return AnimatedPositioned(
duration: Duration(milliseconds: logic.durationTime),
bottom: logic.showMenu ? 0 : -60,
left: 0,
right: 0,
child: Container(
height: 60,
color: logic.isDark ? Colors.black : Colors.white,
padding: const EdgeInsets.only(top: 26, left: 16, right: 16),
alignment: Alignment.topCenter, //按钮贴上沿,余量留给底部安全区
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
enableFeedback: false,
onTap: () => logic.loadOtherEpisode(false, showLoading: true),
child: Row(
children: [
Image.asset('text_last.png'.acgImgPath,
color: AppColors.actionRed, width: 20, height: 20),
12.sizeBoxW,
Text('上一章', style: style),
],
),
),
InkWell(
enableFeedback: false,
onTap: () => logic.loadOtherEpisode(true, showLoading: true),
child: Row(
children: [
Text('下一章', style: style),
12.sizeBoxW,
Image.asset('text_next.png'.acgImgPath,
color: AppColors.actionRed, width: 20, height: 20),
],
),
),
],
),
),
);
}
// 右侧目录抽屉
Widget _buildEndDrawer(TextNovelReadLogic logic) {
final fg = logic.isDark ? Colors.white : Colors.black;
return Container(
color: logic.isDark ? Colors.black : Colors.white,
width: 210,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
80.sizeBoxH,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
logic.mediaInfo?.title ?? '',
style: TextStyle(
fontSize: 16,
color: fg.withValues(alpha: .9),
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
18.sizeBoxH,
// 总集数 + 正倒序切换
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: AppColors.actionRed,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${logic.mediaInfo?.totalEpisode ?? 0}${logic.mediaInfo?.updateDesc ?? ''}',
style: const TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500),
),
InkWell(
enableFeedback: false,
onTap: logic.onChangeSortAction,
child: GetBuilder<TextNovelReadLogic>(
id: 'sort',
builder: (_) => Row(
children: [
AnimatedRotation(
turns: logic.isAscend ? 0 : 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
child: Image.asset('text_play_sort.png'.acgImgPath,
width: 20),
),
4.sizeBoxW,
Text(
logic.isAscend ? '正序' : '倒序',
style: const TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500),
),
],
),
),
),
],
),
),
Expanded(
child: GetBuilder<TextNovelReadLogic>(
id: 'list',
builder: (_) => ListView.builder(
key:
ValueKey(logic.isAscend), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场
itemCount: logic.allEpisodes.length,
padding: const EdgeInsets.only(top: 6),
itemBuilder: (_, index) {
final info = logic.allEpisodes[index];
final select = logic.detailInfo?.id == info.id;
return StaggerInItem(
index: index,
child: InkWell(
enableFeedback:
false, //换掉裸 GestureDetector:整行可点,不再只有文字那一小块
onTap: () {
Get.back();
logic.loadDataWithIndex(info.id ?? '');
},
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(
//与详情/弹窗子集 title 格式一致:第X章
'${info.episodeNumber ?? 1}${logic.mediaInfo?.unit ?? ''}',
style: TextStyle(
fontSize: 16,
color: select
? AppColors.actionRed
: fg.withValues(alpha: .55),
fontWeight:
select ? FontWeight.w500 : FontWeight.w400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
},
),
),
),
],
),
);
}
}
@@ -0,0 +1,49 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
import 'widget/acg_source_manager.dart';
// 有声小说 logic
class VoiceNovelReadLogic extends GetxController {
final String id;
final String? title;
final int index;
final ACGSourceManager sourceManager;
bool isFirstLoad = true;
final AudioPlayerManager audioManager = AudioPlayerManager();
StreamSubscription? _pauseVideoSub;
VoiceNovelReadLogic({
required this.sourceManager,
required this.id,
required this.index,
this.title,
});
@override
void onInit() {
super.onInit();
audioManager.initPlayer();
audioManager.sourceManger = sourceManager;
sourceManager.index = index;
// 耳机/蓝牙断开、来电中断时暂停,防止外放泄露(有声小说为纯音频,外放泄露更直接)
_pauseVideoSub = eventBus.on<PauseVideoEvent>((_) => audioManager.pause());
}
@override
void onReady() {
super.onReady();
isFirstLoad = false;
update();
}
@override
void onClose() {
_pauseVideoSub?.cancel();
audioManager.dispose();
super.onClose();
}
}
@@ -0,0 +1,111 @@
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/hj_utils/widget_util.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:provider/provider.dart';
import 'audio_player_content.dart';
import 'cartoon_recommend_page.dart';
import 'voice_novel_read_logic.dart';
import 'widget/acg_source_manager.dart';
// 有声小说页面
class VoiceNovelReadPage extends StatefulWidget {
final ACGSourceManager manager;
final String id;
final int index;
final String? title;
const VoiceNovelReadPage({
super.key,
required this.manager,
required this.id,
required this.index,
this.title,
});
@override
State<VoiceNovelReadPage> createState() => _VoiceNovelReadPageState();
}
// 可重复 pushpreventDuplicates: false+ 间接环 NovelDetail→VoiceNovelRead 可能多实例并存,
// 用 UniqueTagMixin 给每个实例分独立 tag 隔离
class _VoiceNovelReadPageState extends State<VoiceNovelReadPage>
with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<VoiceNovelReadLogic>(
tag: uniqueTag,
init: VoiceNovelReadLogic(
sourceManager: widget.manager,
id: widget.id,
index: widget.index,
title: widget.title,
),
builder: (logic) => ChangeNotifierProvider<ACGSourceManager>.value(
value: logic.sourceManager,
child: Scaffold(
appBar: AppBar(
leading: InkWell(
enableFeedback: false,
onTap: () => Get.back(),
child: const Icon(Icons.arrow_back_ios,
size: 24, color: Colors.white),
),
centerTitle: false,
titleSpacing: -10,
title: Text(
logic.title ?? '',
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 18.sp,
fontWeight: FontWeight.w600,
),
),
),
body: _buildBody(logic),
),
),
);
}
Widget _buildBody(VoiceNovelReadLogic logic) {
if (logic.isFirstLoad) return LoadingCenterWidget();
if (logic.sourceManager.allEpisodes.isEmpty) return CErrorWidget();
return ExtendedNestedScrollView(
headerSliverBuilder: (_, __) => [
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AudioPlayerContent(
logic.sourceManager,
index: logic.index,
manager: logic.audioManager,
),
),
),
0.5.sliverLine,
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 12),
child: Text(
'相关推荐',
style: textStyle(
18, Colors.white.withValues(alpha: .9), FontWeight.w600),
),
),
),
],
body: CartoonRecommendPage(
mediaId:
logic.sourceManager.mediaInfo?.tagDetails?.firstOrNull?.id ?? '',
padding: const EdgeInsets.only(top: 10),
tapAction: () => logic.audioManager.pause(),
),
);
}
}
@@ -0,0 +1,109 @@
//查看更多
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_utils/widget_util.dart';
class AcgShowMoreTextWidget extends StatefulWidget {
final String? summary;
final int? maxLine;
final double? maxWidth;
final Color txColor;
const AcgShowMoreTextWidget({
super.key,
this.summary,
this.maxLine = 3,
this.maxWidth,
this.txColor = const Color(0xff989898),
});
@override
State<AcgShowMoreTextWidget> createState() => _AcgShowMoreTextWidgetState();
}
class _AcgShowMoreTextWidgetState extends State<AcgShowMoreTextWidget> {
double turns = 1.0; //翻转动画累加值,整数=折叠态,每点击 +0.5 转半圈
bool get _isFold => turns % 1 == 0;
@override
Widget build(BuildContext context) {
//用 LayoutBuilder 拿真实约束宽度测量,避免传入的 maxWidth 与实际渲染宽度不符导致"该展开却没按钮"
return LayoutBuilder(builder: (context, cons) {
final maxWidth = cons.maxWidth.isFinite
? cons.maxWidth
: (widget.maxWidth ?? Get.width);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//宽度拉满 + 居左:短文本也占满整行左对齐,避免父级把窄 Column 居中
SizedBox(
width: double.infinity,
child: Text(
widget.summary?.isNotEmpty == true
? widget.summary ?? ''
: '暂无简介',
textAlign: TextAlign.left,
style: textStyle(12, widget.txColor, FontWeight.w400),
maxLines: _isFold ? widget.maxLine : null,
overflow: _isFold ? TextOverflow.ellipsis : null,
),
),
if (_showMoreText(maxWidth))
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _toggleExpand,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Text(
_isFold ? '更多简介' : '收起简介',
style: textStyle(
12, const Color(0xffEFEFEF), FontWeight.w400),
),
2.sizeBoxW,
AnimatedRotation(
turns: turns,
duration: const Duration(milliseconds: 300),
child: Image.asset(
'acg_narrow.png'.acgImgPath,
width: 18,
color: const Color(0xffDCDCDC),
),
),
],
),
),
),
],
);
});
}
//展开/收起
void _toggleExpand() => setState(() => turns += 0.5);
//文本超过 maxLine 才显示"更多简介"按钮
bool _showMoreText(double maxWidth) {
return _textExceedMaxLines(
widget.summary ?? '.',
textStyle(12, widget.txColor, FontWeight.w400),
widget.maxLine ?? 3,
maxWidth,
);
}
bool _textExceedMaxLines(
String text, TextStyle style, int maxLine, double maxWidth) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: maxLine,
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth);
return painter.didExceedMaxLines;
}
}
@@ -0,0 +1,417 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/cartoon_media_info.dart';
import 'package:hgdj/hj_model/media_content.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
import 'package:hgdj/tools_base/loading/loading_helper.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../../alert/video/comic_buy_alert.dart';
import '../../../alert/video/vip_acg_dialog.dart';
import '../../../hj_model/acg/comic_chapters_model.dart';
import '../../../hj_utils/api_service/acg_service.dart';
import '../../../tools_base/global_store/store.dart';
/// 小说/漫画/有声资源管理器
class ACGSourceManager extends ChangeNotifier {
CartoonMediaInfo? mediaInfo;
List<ComicChapterInfo> allEpisodes = <ComicChapterInfo>[];
final mediaContentCache = <int, MediaContent>{}; // 章节详情缓存
static const _maxPage = 50; // 章节分页拉取上限(50 页 × 60 条 = 3000 章)
static const _pageSize = 60; // 每页集数:一页尽量拉多点,少几轮串行翻页
String id;
int page = 1;
int index = 0; // 当前选中下标,从 0 开始
ACGSourceManager(this.id);
/// 更新收藏状态
void updateCollectState(bool collected) {
mediaInfo?.mediaStatus?.hasCollected = collected;
notifyListeners();
}
/// 点赞/取消点赞
Future<void> onLikeAction() async {
LoadingHelper.showLoading();
final liked = mediaInfo?.mediaStatus?.hasLiked ?? false;
final mid = mediaInfo?.id ?? '';
final type = mediaInfo?.mediaType ?? '';
final suc = liked
? await CommonService.cancelLike(mid, type)
: await CommonService.sendLike(mid, type);
LoadingHelper.dismissLoading();
if (suc) {
mediaInfo?.mediaStatus?.hasLiked = !liked;
// 取消 like 数 -1;点 like 数 +1(原代码两个分支都 -- 是 bug)
mediaInfo?.countLike = (mediaInfo?.countLike ?? 0) + (liked ? -1 : 1);
}
notifyListeners();
}
/// 更新章节购买权限:buyAll=true 整本解锁,否则只解锁 subId 对应章节
void updatePermission({bool? buyAll, String? subId}) {
if (buyAll == true) {
mediaInfo?.mediaStatus?.hasPaid = true;
for (final e in allEpisodes) {
e.hasBuy = true;
}
} else {
for (final e in allEpisodes) {
if (e.id == subId) e.hasBuy = true;
}
}
notifyListeners();
}
/// 阅读按钮文案:第一章=开始阅读;其它=续看 NNN集
String actionTitle() {
final showCount = index + 1;
if (showCount == 1) return "开始阅读";
final showText = showCount.toString().padLeft(3, '0');
return "续看$showText${mediaInfo?.unit ?? ''}";
}
/// 切换当前播放章节
void changeEpisodesIndex(int current, {bool jump = true}) {
index = current;
notifyListeners();
if (jump) eventBus.emit(ACGMenuChanged(index: index));
}
/// 拉所有章节(分页,每页 [_pageSize] 条,最多 [_maxPage] 页)
/// 注:暂不支持刷新重拉——真要加,必须连 mediaContentCache 一起清(它以下标为 key,列表变了会串章)
Future<void> fetchAllEpisodes() async {
// 改为 while 循环:原代码递归没 await;再加页数上限,防后端 hasNext 恒 true 时无限翻页
while (page <= _maxPage) {
final result = await ACGService.getChapterList(id, page, _pageSize);
if (result == null) break;
final list = result.list ?? [];
// 每页拉到就按集号标免费集(与播放放行/角标同一口径),避免全部拉完才标导致角标迟到
for (final e in list) {
e.inFreeEpisode =
mediaInfo?.isFreeEpisodeNumber(e.episodeNumber) ?? false;
}
allEpisodes.addAll(list);
notifyListeners();
if (!(result.hasNext ?? false)) break;
page += 1;
}
}
/// 检测章节权限。permission0=VIP可看 1=金币购买 2=免费
/// 拿到权限后会同时切换 current index 并返回 episode;否则返回 null
Future<ComicChapterInfo?> getEpisodeHasPermisson(int index_) async {
if (allEpisodes.isEmpty) return null;
final epm = allEpisodes[index_];
final permission = mediaInfo?.permission ?? 0;
final unLock = mediaInfo?.mediaStatus?.hasPaid ?? false;
final isPremiumVIP = globalStore.isSuperUp;
final inFreeEpisode = epm.inFreeEpisode; // 本地字段:前 N 集免费,直接放行
bool hasPermission = false;
if (unLock || isPremiumVIP || inFreeEpisode) {
hasPermission = true;
} else {
switch (permission) {
case 2: // 免费
hasPermission = true;
break;
case 0: // 需要 VIP
hasPermission = globalStore.isVIP;
if (!hasPermission) {
await Get.dialog(VipACGDialog(), useSafeArea: false);
}
break;
case 1: // 金币购买
hasPermission = mediaInfo?.mediaStatus?.hasPaid ?? false;
if (!hasPermission) {
final result = await ComicBuyAlert.show(mediaInfo: mediaInfo);
// 1 购买单集 2 购买全集
if (result == 1 || result == 2) {
hasPermission = true;
if (result == 2) updatePermission(buyAll: true);
epm.hasBuy = true;
}
}
break;
}
}
if (hasPermission) changeEpisodesIndex(index_);
return hasPermission ? epm : null;
}
/// 拉漫画章节详情(带缓存 + 权限校验)
Future<MediaContent?> getComicsMediaContent(
{int index = 0, bool showLoading = false}) async {
final cached = mediaContentCache[index];
if (cached != null) {
changeEpisodesIndex(index); // 命中缓存也要同步当前章,否则退回详情页「续看」还停在旧章
return cached;
}
final episode = await getEpisodeHasPermisson(index);
if (episode == null) return null;
// loading 开在权限校验之后:VIP/购买弹窗不能被 loading 遮罩挡住
if (showLoading) LoadingHelper.showLoading();
final result = await ACGService.getMediaDetail(id: episode.id ?? '');
if (showLoading) LoadingHelper.dismissLoading();
if (result != null) mediaContentCache[index] = result;
return result;
}
/// 上一集
Future<ComicChapterInfo?> backToForward() async {
if (index == 0) {
showToast('当前是第一章~~~');
return null;
}
return getEpisodeHasPermisson(index - 1);
}
/// 下一集
Future<ComicChapterInfo?> jumpToNext() async {
if (index == allEpisodes.length - 1) {
showToast('当前是最后一章了~~~');
return null;
}
return getEpisodeHasPermisson(index + 1);
}
}
enum AudioPlayMod {
loop,
loopList,
}
class AudioPlayerValue {
Duration? position;
Duration? duration;
bool? isPlaying;
bool isLooping;
double volume;
bool isCompleted;
bool isInitialized;
int? onPlayIndex;
AudioPlayerValue({
this.position,
this.duration,
this.isPlaying,
this.volume = 1,
this.isLooping = true,
this.isCompleted = false,
this.isInitialized = false,
this.onPlayIndex,
});
static AudioPlayerValue copyWith(AudioPlayerValue value) {
return AudioPlayerValue(
duration: value.duration,
position: value.position,
isPlaying: value.isPlaying,
isCompleted: value.isCompleted,
volume: value.volume,
isLooping: value.isLooping,
isInitialized: value.isInitialized,
onPlayIndex: value.onPlayIndex,
);
}
}
/// 有声小说播放器封装:包装 audioplayers + 列表切换
class AudioPlayerManager {
AudioPlayerManager() {
audioValue = ValueNotifier(value);
}
AudioPlayer? _audioPlayer;
AudioPlayMod playMod = AudioPlayMod.loopList;
ACGSourceManager? sourceManger;
StreamSubscription? _audioPositionSub;
StreamSubscription? _audioDurationSub;
StreamSubscription? _audioStateSub;
StreamSubscription? _audioCompleSub;
StreamSubscription? _seekSub;
final AudioPlayerValue value = AudioPlayerValue();
late ValueNotifier<AudioPlayerValue> audioValue;
double playSpeed = 1;
void initPlayer() {
if (_audioPlayer != null)
return; // 防重入:已初始化则跳过,避免 5 个 stream 监听重复注册、旧 sub 泄漏
_audioPlayer = AudioPlayer();
_audioPlayer?.setReleaseMode(
playMod == AudioPlayMod.loop ? ReleaseMode.loop : ReleaseMode.stop);
_audioDurationSub = _audioPlayer?.onDurationChanged.listen((event) {
audioValue.value = AudioPlayerValue.copyWith(value..duration = event);
});
_audioPositionSub = _audioPlayer?.onPositionChanged.listen((event) {
audioValue.value = AudioPlayerValue.copyWith(value
..position = event
..isPlaying = true
..onPlayIndex = sourceManger?.index ?? 0);
});
_audioStateSub = _audioPlayer?.onPlayerStateChanged.listen((event) {
switch (event) {
case PlayerState.completed:
audioValue.value = AudioPlayerValue.copyWith(value
..isCompleted = true
..position = null);
break;
case PlayerState.playing:
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
break;
case PlayerState.paused:
audioValue.value =
AudioPlayerValue.copyWith(value..isPlaying = false);
break;
default:
break;
}
});
_audioCompleSub = _audioPlayer?.onPlayerComplete.listen((event) {
if (playMod == AudioPlayMod.loopList && (value.isPlaying ?? false)) {
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
playNext();
} else {
audioValue.value = AudioPlayerValue.copyWith(value
..isCompleted = true
..isPlaying = false);
}
});
_seekSub = _audioPlayer?.onSeekComplete.listen((event) {
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
});
audioValue.value =
AudioPlayerValue.copyWith(value..isInitialized = _audioPlayer != null);
}
void setPlaySpeed(double speed) {
playSpeed = speed;
_audioPlayer?.setPlaybackRate(speed);
}
/// 开始播放:url 为空则重置状态
void play(String url) {
if (_audioPlayer == null) initPlayer();
audioValue.value = AudioPlayerValue.copyWith(
value
..isPlaying = false
..duration = null
..position = Duration.zero,
);
if (url.isEmpty) return;
// play 是 Future 但不 await(保留 fire-and-forget 行为);
// 必须挂 catchError,否则 audioplayers 30s 没收到 prepared 时抛 TimeoutException
// 会变成未处理异常炸 consoleUI 也卡在 "加载中"
_audioPlayer?.play(UrlSource(url)).catchError((Object e) {
debugPrint('[AudioPlayer] play failed: $e');
audioValue.value = AudioPlayerValue.copyWith(value
..isPlaying = false
..isCompleted = true
..duration = null);
showToast('音频加载失败');
});
}
/// 上一集
Future<void> playForward() async {
final epm = await sourceManger?.backToForward();
if (epm != null) {
play(epm.getRealAudioUrl);
} else {
pause();
}
}
/// 下一集
Future<void> playNext() async {
final epm = await sourceManger?.jumpToNext();
if (epm != null) {
play(epm.getRealAudioUrl);
} else {
pause();
}
}
Future<void> pause() async {
await _audioPlayer?.pause();
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
}
Future<void> resume() async {
await _audioPlayer?.resume();
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
}
void setPlayMod(AudioPlayMod mod) {
_audioPlayer?.setReleaseMode(
mod == AudioPlayMod.loop ? ReleaseMode.loop : ReleaseMode.stop);
playMod = mod;
audioValue.value =
AudioPlayerValue.copyWith(value..isLooping = mod == AudioPlayMod.loop);
}
Future<void> playWithIndex(int index) async {
if (index == sourceManger?.index) {
if (!(value.isPlaying ?? false)) resume();
return;
}
final epm = await sourceManger?.getEpisodeHasPermisson(index);
if (epm != null) {
play(epm.getRealAudioUrl);
} else {
pause();
}
}
/// 跳转到指定秒数
void seek(int seconds) {
_audioPlayer?.seek(Duration(seconds: seconds));
if (value.isPlaying ?? false) {
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
}
}
/// 前进/后退 10 秒(dir: -1 后退,1 前进)
void seekTenMinsWithDirection(int dir) {
final cur = audioValue.value.position?.inSeconds ?? 0;
final total = audioValue.value.duration?.inSeconds ?? 0;
int target;
if (dir == -1) {
target = cur < 10 ? 0 : cur - 10;
} else {
target = (total - cur) < 10 ? total : cur + 10;
}
seek(target);
}
void dispose() {
_audioPlayer?.pause();
_audioPlayer?.release();
_audioPlayer = null;
_audioPositionSub?.cancel();
_audioDurationSub?.cancel();
_audioStateSub?.cancel();
_audioCompleSub?.cancel();
_seekSub?.cancel();
}
}
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/acg/comic_chapters_model.dart';
import 'package:hgdj/hj_model/cartoon_media_info.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
import 'acg_source_manager.dart';
import 'free_badge.dart';
//漫画子集底部弹窗
class CartoonSubSetAlert extends StatefulWidget {
final ACGSourceManager? sourceManager;
const CartoonSubSetAlert({super.key, this.sourceManager});
@override
State<CartoonSubSetAlert> createState() => _CartoonSubSetAlertState();
}
class _CartoonSubSetAlertState extends State<CartoonSubSetAlert> {
CartoonMediaInfo? get mediaInfo => widget.sourceManager?.mediaInfo;
List<ComicChapterInfo> allEpisodes = [];
bool isAscending = true; // true-升序 false-降序
@override
void initState() {
super.initState();
allEpisodes.addAll(widget.sourceManager?.allEpisodes ?? []);
}
@override
Widget build(BuildContext context) {
return Container(
width: Get.width,
height: 425,
decoration: const BoxDecoration(
color: AppColors.primaryColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
children: [
18.sizeBoxH,
const SheetHandleBar(color: Color(0xff989898)),
18.sizeBoxH,
_buildHeader(),
Expanded(child: _buildEpisodeList()),
],
),
);
}
Widget _buildHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
16.sizeBoxW,
const Text(
'漫画列表',
style: TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
),
10.sizeBoxW,
const Spacer(),
InkWell(
enableFeedback: false,
onTap: changeSortType,
child: Row(
children: [
AnimatedRotation(
turns: isAscending ? 0 : 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
child: Image.asset('text_play_sort.png'.acgImgPath, width: 20),
),
4.sizeBoxW,
Text(
isAscending ? '正序' : '倒序',
style: TextStyle(
color: Colors.white.withValues(alpha: .5),
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
],
),
),
22.sizeBoxW,
],
);
}
Widget _buildEpisodeList() {
return ListView.builder(
key: ValueKey(isAscending), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场
padding: const EdgeInsets.symmetric(vertical: 9),
itemCount: allEpisodes.length,
itemBuilder: (context, index) {
final info = allEpisodes[index];
final realIndex = (info.episodeNumber ?? 1) - 1;
final sel = realIndex == widget.sourceManager?.index;
return StaggerInItem(
index: index,
child: InkWell(
enableFeedback: false,
onTap: () => Get.back(result: realIndex),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
child: Row(
children: [
Text(
//与详情/阅读抽屉子集 title 一致:第X话(CartoonDetailPage 只处理 image,恒为漫画)
'${info.episodeNumber ?? ''}${mediaInfo?.unit ?? ''}',
style: TextStyle(
color: sel
? Colors.white.withValues(alpha: .9)
: Colors.white.withValues(alpha: .45),
fontSize: 16,
fontWeight: FontWeight.w400,
),
),
//无任何权限时,前 N 集免费展示「免费」角标
if (mediaInfo?.hasPermission == false &&
info.inFreeEpisode) ...[
8.sizeBoxW,
const FreeBadge(),
],
],
),
),
),
);
},
);
}
void changeSortType() {
isAscending = !isAscending;
final list = widget.sourceManager?.allEpisodes ?? [];
allEpisodes = isAscending ? List.of(list) : list.reversed.toList();
setState(() {});
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
/// ACG「免费」角标:整本无任何权限(hasPermission == false)且该集落在前 N 集免费区间时展示。
/// 默认行内胶囊(详情页/子集抽屉/有声列表);[isCorner] 为卡片左上角贴边样式(选集面板小方块)。
class FreeBadge extends StatelessWidget {
final bool isCorner;
const FreeBadge({super.key, this.isCorner = false});
static const _bgColor = Color(0xff64CBC2);
@override
Widget build(BuildContext context) {
if (isCorner) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: const BoxDecoration(
color: _bgColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(7),
bottomRight: Radius.circular(7),
),
),
child: const Text(
'免费',
style: TextStyle(color: Colors.white, fontSize: 9, height: 1.2, fontWeight: FontWeight.w400),
),
);
}
return Container(
height: 20,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: _bgColor,
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'免费',
style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w400),
),
);
}
}