初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -0,0 +1,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),
),
);
}
}