初始化

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