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 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 _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(); // 首次加载时列表还没 build,controller 未 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; } }