Files
2026-09-15 15:44:13 +07:00

211 lines
7.3 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}