初始化
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
|
||||
import '../../../hj_model/video_model.dart';
|
||||
import '../../../tools_base/toast.dart';
|
||||
import '../../../tools_base/video_download/video_save_util.dart';
|
||||
import '../../../tools_base/video_download/video_download_manager.dart';
|
||||
import '../../../tools_base/widget/net_image_widget.dart';
|
||||
import '../../home/home_cell_style/video_simple_cell.dart';
|
||||
import 'video_cache_logic.dart';
|
||||
|
||||
class VideoCacheCell extends StatefulWidget {
|
||||
final VideoCacheLogic logic; // 注册局部刷新回调用
|
||||
final VideoModel model;
|
||||
final MediaStyle style; // 影视 / 抖音 / 动漫 / 短剧
|
||||
final bool isEditing; // 缓存编辑状态
|
||||
final GestureTapCallback? onCacheTap;
|
||||
|
||||
/// 点卡片进播放页。必须交给内层 [VideoSimpleCell]——它自带手势且在更深一层,
|
||||
/// 只在外面套 GestureDetector 的话手势竞技场里赢的是它,跳转会走它默认的那套(按时长分长/短视频)
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
const VideoCacheCell({
|
||||
super.key,
|
||||
required this.logic,
|
||||
required this.model,
|
||||
required this.style,
|
||||
this.isEditing = false,
|
||||
this.onCacheTap,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoCacheCell> createState() => _VideoCacheCellState();
|
||||
}
|
||||
|
||||
class _VideoCacheCellState extends State<VideoCacheCell> {
|
||||
/// 覆盖层状态切换动画时长:编辑态/缓存按钮/开始结束/勾选态统一用它
|
||||
static const _switchDuration = Duration(milliseconds: 200);
|
||||
|
||||
VideoModel get model => widget.model;
|
||||
|
||||
bool get isDownloading => model.isDownloading;
|
||||
bool get isFinished => model.progress >= 1;
|
||||
|
||||
String get statusText {
|
||||
if (isFinished) return "已完成";
|
||||
if (isDownloading) return "下载中...";
|
||||
return "已暂停";
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 向 logic 注册局部刷新:下载回调更新 model 后只 setState 本 cell,不再整页 update()
|
||||
widget.logic.registerCellRefresher(model, _refresh);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant VideoCacheCell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 切换到不同视频时换绑刷新回调。
|
||||
// 用 realVideoUrl 而非 id 比较:本地缓存记录的 id 可能都是 "-1",比不出差异会漏换绑
|
||||
if (oldWidget.model.realVideoUrl != model.realVideoUrl) {
|
||||
widget.logic.unregisterCellRefresher(oldWidget.model, _refresh);
|
||||
widget.logic.registerCellRefresher(model, _refresh);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.logic.unregisterCellRefresher(model, _refresh);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// logic 的下载回调命中本 cell 的 url 时触发:model 已被 logic 更新,这里只需重建
|
||||
void _refresh() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _toggleSelected() {
|
||||
model.isSelected = !model.isSelected;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// 短剧按集卖,而本地记录里的播放地址是当初解锁时拿到的,权益到期后它照样能用
|
||||
/// (m3u8 接口只认登录 token,不认单集权益)。**离线播放刻意不拦**——下载就是为了离线看,
|
||||
/// 点卡片直接放本地文件(见 VideoCachePage._openVideo);这里只管两个会往外扩散的动作:
|
||||
/// 导出相册、续下载
|
||||
Future<bool> _ensureUnlocked() async {
|
||||
if (widget.style != MediaStyle.Drama) return true;
|
||||
final episode = await DramaService.fetchEpisode(model.subid);
|
||||
if (episode?.canPlay == true) return true;
|
||||
showToast("该剧集权益已过期,请重新解锁");
|
||||
return false;
|
||||
}
|
||||
|
||||
void _saveToAlbum() async {
|
||||
if (!await _ensureUnlocked()) return;
|
||||
VideoSaveUtil.instance.convertVideoMp4(
|
||||
model.realVideoUrl,
|
||||
loadInfo: DownloadInfo(status: "1", localPath: model.localPath),
|
||||
isShowLoading: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _onCacheTap() async {
|
||||
if (!await _ensureUnlocked()) return;
|
||||
widget.onCacheTap?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xff151515),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildBody()),
|
||||
_buildStatus(),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// cell 主体:动漫用 ACG 版本,其它(含短剧)用通用视频 cell
|
||||
Widget _buildBody() {
|
||||
if (widget.style == MediaStyle.Cartoon) {
|
||||
return VideoACGCacheCell(model: model);
|
||||
}
|
||||
final isDrama = widget.style == MediaStyle.Drama;
|
||||
return VideoSimpleCell(
|
||||
videoModel: model,
|
||||
onTap: widget.onTap,
|
||||
textLines: 1,
|
||||
imgBorderRadius: BorderRadius.circular(8),
|
||||
//短剧和「热门短剧」橱窗一个样式:标题是剧名,封面右下角标本条是第几集;
|
||||
//按集卖,整部剧标一个金币/VIP 角标对不上,跟橱窗一样关掉
|
||||
coverRightText: isDrama ? '第${model.episodeNo ?? 1}集' : null,
|
||||
showLevelIcon: !isDrama,
|
||||
);
|
||||
}
|
||||
|
||||
/// 状态文案 + 保存按钮 + 进度条
|
||||
Widget _buildStatus() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
// Expanded + ellipsis:吸收富余宽度,抖音 3 列窄格里不再右溢出
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Color(0xffd43f61), height: 1.5),
|
||||
),
|
||||
),
|
||||
// 仅下载完成且非动漫显示"保存到相册",避免下载中误触 + 右侧溢出
|
||||
if (widget.style != MediaStyle.Cartoon && isFinished) ...[
|
||||
6.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _saveToAlbum,
|
||||
child: const Text(
|
||||
"保存到相册",
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFFF68804),
|
||||
height: 1.5,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 14,
|
||||
child: Opacity(
|
||||
opacity: isFinished ? 0 : 1,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: model.progress,
|
||||
backgroundColor: const Color(0xff727272),
|
||||
color: const Color(0xffe75160),
|
||||
),
|
||||
),
|
||||
),
|
||||
5.sizeBoxW,
|
||||
// 固定宽度,避免百分比数字位数变化导致进度条右边跳动
|
||||
SizedBox(
|
||||
width: 46,
|
||||
child: Text(
|
||||
"${(model.progress * 100).toStringAsFixed(1)}%",
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Color(0xffaab2b7), height: 1.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 覆盖层三态切换(编辑勾选 / 缓存按钮 / 下载完成隐藏):淡入淡出,不再硬跳。
|
||||
/// 三个分支 runtimeType 各不相同,AnimatedSwitcher 自动识别为切换;
|
||||
/// 而分支内部的 isDownloading / selected 变化 type 与 key 都不变,交给各自内层动画,蒙层不跟着闪。
|
||||
Widget _buildOverlay() {
|
||||
return AnimatedSwitcher(
|
||||
duration: _switchDuration,
|
||||
// 显式 expand:改造前蒙层是 StackFit.expand 的直接 child(紧约束铺满 cell),
|
||||
// 套 AnimatedSwitcher 后默认布局会变成松约束按内容撑开,这里保持原来的铺满行为
|
||||
layoutBuilder: (currentChild, previousChildren) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 正在淡出的旧层要屏蔽点击:它还在树里,否则切换的这 200 毫秒内
|
||||
// 可能点到已经切走的按钮(刚进编辑模式却点中"开始缓存")
|
||||
...previousChildren.map((c) => IgnorePointer(child: c)),
|
||||
if (currentChild != null) currentChild,
|
||||
],
|
||||
),
|
||||
child: () {
|
||||
if (widget.isEditing) return _buildSelectLayer();
|
||||
if (isFinished) return const SizedBox.shrink();
|
||||
return _buildDownloadButton();
|
||||
}(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 开始 / 结束缓存按钮
|
||||
Widget _buildDownloadButton() {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: .1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: _onCacheTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 72),
|
||||
height: 42,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
// 开始/结束 图标与文案切换:只动这一行,外层蒙层与按钮底不动。
|
||||
// 两种文案都是 4 个字 + 同宽图标,切换期间两者叠在 Stack 里不会撑宽按钮
|
||||
child: AnimatedSwitcher(
|
||||
duration: _switchDuration,
|
||||
child: Row(
|
||||
key: ValueKey(isDownloading),
|
||||
mainAxisSize: MainAxisSize.min, // 按内容收窄,不再固定 120 宽
|
||||
children: [
|
||||
Image.asset(
|
||||
(isDownloading ? "cache_stop.webp" : "cache_start.webp")
|
||||
.videoPath,
|
||||
width: 10,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
6.sizeBoxW,
|
||||
Text(
|
||||
isDownloading ? "结束缓存" : "开始缓存",
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编辑态勾选层
|
||||
Widget _buildSelectLayer() {
|
||||
final selected = model.isSelected;
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _toggleSelected,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: const Color(0x99707070),
|
||||
),
|
||||
child: Center(
|
||||
// 勾选态切换:淡入 + 轻微放大,点击有即时反馈(scale 从 .7 起,不从 0 免得过于夸张)
|
||||
child: AnimatedSwitcher(
|
||||
duration: _switchDuration,
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: .7, end: 1.0).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Image.asset(
|
||||
(selected ? "cache_selected.webp" : "cache_unselected.webp")
|
||||
.videoPath,
|
||||
key: ValueKey(selected),
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoACGCacheCell extends StatelessWidget {
|
||||
final VideoModel model;
|
||||
|
||||
const VideoACGCacheCell({super.key, required this.model});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: model.cover ?? "",
|
||||
imgBorderRadius: BorderRadius.circular(12),
|
||||
borderRadius: 4,
|
||||
),
|
||||
),
|
||||
4.sizeBoxH,
|
||||
Text(
|
||||
model.title ?? '',
|
||||
style: const TextStyle(fontSize: 14, color: Colors.white),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
4.sizeBoxH,
|
||||
Text(
|
||||
'${model.updateDesc} · 共${model.totalEpisode ?? 0}话',
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color:
|
||||
model.updateStatus == 2 ? Color(0xff757575) : Color(0xffEEC76B),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../../hj_model/video_model.dart';
|
||||
import '../../../tools_base/video_download/video_cache_store.dart';
|
||||
import '../../../tools_base/video_download/video_download_manager.dart';
|
||||
|
||||
class VideoCacheLogic extends GetxController with GetTickerProviderStateMixin {
|
||||
/// 删除退场动画时长:cell 缩放淡出播完才真删数据,page 侧动画时长与此保持一致
|
||||
static const removeAnimDuration = Duration(milliseconds: 260);
|
||||
|
||||
//顺序与 _cacheMap 一致
|
||||
final tabStyles = const [
|
||||
MediaStyle.Video,
|
||||
MediaStyle.ShortVideo,
|
||||
MediaStyle.Cartoon,
|
||||
MediaStyle.Drama
|
||||
];
|
||||
|
||||
late final tabTitles = tabStyles.map((e) => e.cacheTabTitle).toList();
|
||||
|
||||
//在 onInit 里赋值而不是写成 late final 惰性初始化:那样 onClose 里的 dispose
|
||||
//会成为「首次访问」,在控制器已销毁时才去 createTicker
|
||||
late final TabController tabCtr;
|
||||
|
||||
/// 各类型缓存列表;短剧按**集**存,一部剧会有多条记录
|
||||
final _cacheMap = {
|
||||
MediaStyle.Video: <VideoModel>[],
|
||||
MediaStyle.ShortVideo: <VideoModel>[],
|
||||
MediaStyle.Cartoon: <VideoModel>[],
|
||||
MediaStyle.Drama: <VideoModel>[],
|
||||
};
|
||||
|
||||
List<VideoModel> listOf(MediaStyle style) => _cacheMap[style] ?? const [];
|
||||
|
||||
List<VideoModel> get allVideos =>
|
||||
[for (final list in _cacheMap.values) ...list];
|
||||
|
||||
bool isLoading = true;
|
||||
bool isEditing = false;
|
||||
|
||||
/// 删除流程进行中,防止 await 期间重复点「删除」
|
||||
bool _isDeleting = false;
|
||||
|
||||
/// 正在播退场动画的 item:动画期间仍留在列表里,播完才真删数据
|
||||
final _removingItems = <VideoModel>{};
|
||||
|
||||
bool isRemoving(VideoModel model) => _removingItems.contains(model);
|
||||
|
||||
/// taskKey -> 对应 cell 的局部刷新回调。
|
||||
/// 下载进度不再触发整页 update(),改为只 setState 命中 url 的 cell(局部刷新)。
|
||||
/// 用 Set 兜底同一 url 出现在多个 cell 的极端情况。
|
||||
final _cellRefreshers = <String, Set<VoidCallback>>{};
|
||||
|
||||
/// 下载回调:Android isolate / iOS task 两端都靠它回传,命中哪条只刷哪个 cell
|
||||
late final _callback = DownloadCallback(
|
||||
success: (url) async {
|
||||
final model = _findByUrl(url);
|
||||
model?.loadProgress = "100.00";
|
||||
// 实时完成时 localPath 还没回填(只在进页面 searchInfo 填过一次,那会儿还没下完),
|
||||
// 这里补查一次写回,否则点"保存到相册"会因 localPath 为空报"视频文件不存在"
|
||||
final info = await VideoDownloadManager.instance.searchInfo(url: url);
|
||||
if (info != null) model?.localPath = info.localPath;
|
||||
_refreshCells(url);
|
||||
},
|
||||
fail: (url, error) {
|
||||
_findByUrl(url)?.isLoaderRunning = "0";
|
||||
showToast("缓存加载失败");
|
||||
_refreshCells(url);
|
||||
},
|
||||
progress: (url, progress) {
|
||||
_findByUrl(url)?.loadProgress = progress;
|
||||
_refreshCells(url);
|
||||
},
|
||||
);
|
||||
|
||||
/// cell 挂载时注册自己的局部刷新回调
|
||||
void registerCellRefresher(VideoModel model, VoidCallback refresh) {
|
||||
_cellRefreshers
|
||||
.putIfAbsent(VideoDownloadManager.taskKey(model.realVideoUrl), () => {})
|
||||
.add(refresh);
|
||||
}
|
||||
|
||||
/// cell 卸载 / 换绑视频时注销
|
||||
void unregisterCellRefresher(VideoModel model, VoidCallback refresh) {
|
||||
final key = VideoDownloadManager.taskKey(model.realVideoUrl);
|
||||
final set = _cellRefreshers[key];
|
||||
if (set == null) return;
|
||||
set.remove(refresh);
|
||||
if (set.isEmpty) _cellRefreshers.remove(key);
|
||||
}
|
||||
|
||||
/// 精准刷新某 url 对应的 cell(替代原来下载回调里的全页 update())
|
||||
void _refreshCells(String url) {
|
||||
final set = _cellRefreshers[VideoDownloadManager.taskKey(url)];
|
||||
if (set == null) return;
|
||||
for (final refresh in Set.of(set)) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 taskKey 在列表里找到对应 model(url 带 token/cdn,直接比会漏匹配)
|
||||
VideoModel? _findByUrl(String url) {
|
||||
final key = VideoDownloadManager.taskKey(url);
|
||||
for (final item in allVideos) {
|
||||
if (VideoDownloadManager.taskKey(item.realVideoUrl) == key) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
tabCtr = TabController(length: tabStyles.length, vsync: this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
|
||||
void _loadData() async {
|
||||
try {
|
||||
for (final entry in _cacheMap.entries) {
|
||||
entry.value
|
||||
..clear()
|
||||
..addAll(
|
||||
await VideoCacheStore.instance.getMovieCacheVideoList(entry.key));
|
||||
}
|
||||
// 本地记录只有 url,进度/暂停态要逐条问下载器;顺带把 _callback 挂上去
|
||||
for (final item in allVideos) {
|
||||
//条目多时这个循环很慢,用户中途退页面就别再往下问:
|
||||
//onClose 已经把回调摘干净了,这里再 searchInfo 会把 _callback 重新挂回单例,留下悬挂回调
|
||||
if (isClosed) return;
|
||||
final info = await VideoDownloadManager.instance.searchInfo(
|
||||
url: item.realVideoUrl,
|
||||
callback: _callback,
|
||||
);
|
||||
if (info != null) {
|
||||
item.localPath = info.localPath;
|
||||
item.loadProgress = info.progress;
|
||||
item.isLoaderRunning = info.isLoaderRunning;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
isLoading = false;
|
||||
if (!isClosed) update();
|
||||
}
|
||||
|
||||
/// 右上角按钮:非编辑态进编辑,编辑态执行删除
|
||||
void editEvent() {
|
||||
if (_isDeleting) return; // 删除动画/落盘期间不响应重复点击
|
||||
if (isEditing) {
|
||||
_deleteSelected();
|
||||
return;
|
||||
}
|
||||
//进编辑先清掉上轮残留的勾选,否则再次进编辑会显示脏选中态
|
||||
//(删除失败时未删项、或退场动画那 260ms 里新勾的项,都会残留 isSelected)
|
||||
for (final item in allVideos) {
|
||||
item.isSelected = false;
|
||||
}
|
||||
isEditing = true;
|
||||
update();
|
||||
}
|
||||
|
||||
/// 先标记被选中项播退场动画,动画播完再真删,避免 cell 硬闪消失
|
||||
void _deleteSelected() async {
|
||||
final deleteList = allVideos.where((e) => e.isSelected).toList();
|
||||
if (deleteList.isEmpty) {
|
||||
isEditing = false;
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
_isDeleting = true;
|
||||
_removingItems.addAll(deleteList);
|
||||
update();
|
||||
try {
|
||||
await Future.delayed(removeAnimDuration);
|
||||
for (final item in deleteList) {
|
||||
await VideoDownloadManager.instance.delete(item.realVideoUrl);
|
||||
}
|
||||
await VideoCacheStore.instance.removeVideoListNoType(deleteList);
|
||||
for (final list in _cacheMap.values) {
|
||||
list.removeWhere(deleteList.contains);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
showToast("删除失败");
|
||||
} finally {
|
||||
// 必须复位,否则一次异常就把页面卡死:_isDeleting 会让「删除」按钮永久失效,
|
||||
// _removingItems 残留会让那几个 cell 一直保持透明
|
||||
_removingItems.clear();
|
||||
isEditing = false;
|
||||
_isDeleting = false;
|
||||
// 删除期间用户可能已退页面(GetBuilder 会 dispose logic),落盘照做但别再刷 UI
|
||||
if (!isClosed) update();
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始 / 暂停下载
|
||||
void toggleDownload(VideoModel model) async {
|
||||
if (model.isDownloading) {
|
||||
await VideoDownloadManager.instance.pause(model.realVideoUrl);
|
||||
model.isLoaderRunning = "0";
|
||||
} else {
|
||||
// 开始新任务前,先暂停其它正在下载的任务(同时只允许一个下载)
|
||||
for (final item in allVideos) {
|
||||
if (item.isDownloading) {
|
||||
await VideoDownloadManager.instance.pause(item.realVideoUrl);
|
||||
item.isLoaderRunning = "0";
|
||||
}
|
||||
}
|
||||
final result = await VideoDownloadManager.instance.download(
|
||||
url: model.realVideoUrl,
|
||||
callback: _callback,
|
||||
);
|
||||
if (result != null) {
|
||||
debugLog("download movie file: $result");
|
||||
} else {
|
||||
model.isLoaderRunning = "1";
|
||||
}
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
// 只注销本页自己注册的那个 _callback,绝不能清光单例里的全部回调:
|
||||
// 那会连短视频页 DownloadButton 等别处组件的回调一起清掉,
|
||||
// 表现为「短视频页点下载 → 来缓存页看一眼 → 退回去,那个按钮的进度就再也不动了」。
|
||||
// _callback 是同一个实例注册在多个 url 上(_loadData 逐个 searchInfo 都传了它),故逐个摘除
|
||||
for (final item in allVideos) {
|
||||
VideoDownloadManager.instance
|
||||
.removeCallback(item.realVideoUrl, _callback);
|
||||
}
|
||||
_cellRefreshers.clear();
|
||||
tabCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.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/widget/keep_alive_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
|
||||
|
||||
import '../../../hj_model/drama_media_info.dart';
|
||||
import '../../../hj_model/video_model.dart';
|
||||
import '../../../tools_base/indicator/custom_tab_indicator.dart';
|
||||
import '../../drama/drama_detail_page.dart';
|
||||
import '../../video/simple_video_player_page.dart';
|
||||
import 'video_cache_cell.dart';
|
||||
import 'video_cache_logic.dart';
|
||||
|
||||
class VideoCachePage extends StatelessWidget {
|
||||
const VideoCachePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<VideoCacheLogic>(
|
||||
init: VideoCacheLogic(),
|
||||
global: false,
|
||||
builder: (logic) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: Text("下载缓存"),
|
||||
actions: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.editEvent(),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
logic.isEditing ? "删除" : "编辑",
|
||||
style: const TextStyle(
|
||||
color: Color(0xff757575),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
],
|
||||
),
|
||||
body: logic.isLoading
|
||||
? LoadingCenterWidget()
|
||||
: Column(
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).appBarTheme.backgroundColor,
|
||||
child: TabBar(
|
||||
//tab 等分整宽,不滚动
|
||||
indicator: CustomIndicator(isGradient: true),
|
||||
indicatorWeight: 1,
|
||||
unselectedLabelColor: Color(0x8CFFFFFF),
|
||||
unselectedLabelStyle: TextStyle(fontSize: 14),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w500),
|
||||
labelColor: Color(0xE5FFFFFF),
|
||||
tabs: logic.tabTitles
|
||||
.map(
|
||||
(e) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 5, 0, 5),
|
||||
child: Text(e),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
controller: logic.tabCtr,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: logic.tabCtr,
|
||||
children: [
|
||||
// keepAlive:切 tab 不重建,保留滚动位置,也避免入场动画反复重播
|
||||
for (final style in logic.tabStyles)
|
||||
_buildGrid(logic, style).keepAlive,
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrid(VideoCacheLogic logic, MediaStyle style) {
|
||||
final dataArr = logic.listOf(style);
|
||||
if (dataArr.isEmpty) {
|
||||
return CErrorWidget();
|
||||
}
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
//短剧跟「热门短剧」橱窗同规格:2 列 168/266
|
||||
crossAxisCount:
|
||||
style == MediaStyle.ShortVideo || style == MediaStyle.Cartoon
|
||||
? 3
|
||||
: 2,
|
||||
crossAxisSpacing: 6,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: switch (style) {
|
||||
MediaStyle.ShortVideo => 191 / 390,
|
||||
MediaStyle.Cartoon => 191 / 420,
|
||||
MediaStyle.Drama => 168 / 266,
|
||||
_ => 191 / 210,
|
||||
},
|
||||
),
|
||||
itemCount: dataArr.length,
|
||||
itemBuilder: (context, int index) {
|
||||
final videoModel = dataArr[index];
|
||||
return StaggerInItem(
|
||||
// key 用 model 对象身份而非 index:删除后剩余项 Element 按 key 复用、
|
||||
// 各自 State 保留,既不重播入场动画(否则整屏闪一下),也不会串味。
|
||||
// 用 ObjectKey 而不是 url/taskKey:同一 url 可能对应多条记录(见 _cellRefreshers 的注释),
|
||||
// 那样会撞成重复 key 直接抛 Duplicate keys;对象身份天然唯一且删除后不变
|
||||
key: ObjectKey(videoModel),
|
||||
index: index,
|
||||
child: _buildCell(logic, videoModel, style),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 单个缓存 cell:被标记删除时缩放 + 淡出(时长与 logic 一致,播完 logic 才真删数据)
|
||||
Widget _buildCell(
|
||||
VideoCacheLogic logic, VideoModel videoModel, MediaStyle style) {
|
||||
final isRemoving = logic.isRemoving(videoModel);
|
||||
// 退场中屏蔽点击:opacity 到 0 也照样命中手势,否则那 260 毫秒里
|
||||
// 点到看不见的 cell 会跳进播放页
|
||||
return IgnorePointer(
|
||||
ignoring: isRemoving,
|
||||
child: AnimatedOpacity(
|
||||
opacity: isRemoving ? 0 : 1,
|
||||
duration: VideoCacheLogic.removeAnimDuration,
|
||||
curve: Curves.easeOut,
|
||||
child: AnimatedScale(
|
||||
scale: isRemoving ? .8 : 1,
|
||||
duration: VideoCacheLogic.removeAnimDuration,
|
||||
curve: Curves.easeOut,
|
||||
child: GestureDetector(
|
||||
//动漫 cell 自身没有手势,靠这一层接管;其余 cell 内层 VideoSimpleCell 也收了同一个回调
|
||||
onTap: () => _openVideo(videoModel, style),
|
||||
child: VideoCacheCell(
|
||||
logic: logic,
|
||||
model: videoModel,
|
||||
style: style,
|
||||
isEditing: logic.isEditing,
|
||||
onCacheTap: () => logic.toggleDownload(videoModel),
|
||||
onTap: () => _openVideo(videoModel, style),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 点缓存卡片进播放页
|
||||
void _openVideo(VideoModel videoModel, MediaStyle style) {
|
||||
//下载就是为了离线看:有本地文件就直接放,不进二级页——二级页是纯网络播放器
|
||||
//(VideoPlayerBaseLogic.initPlayer 只认 network),进那儿等于没用上下好的文件,断网还打不开
|
||||
if (style == MediaStyle.Drama && videoModel.localPath?.isNotEmpty == true) {
|
||||
_playLocal(videoModel,
|
||||
'${videoModel.title ?? ""} 第${videoModel.episodeNo ?? 1}集');
|
||||
return;
|
||||
}
|
||||
//没下完(或只有下载态没落到文件)的没有本地文件可放,进二级页在线看这一集:
|
||||
//本地记录只留了剧 id 和分集 id,短剧不在 /vid/info 里,走 pushToVideoPage 那套只会拿剧 id 去查视频详情
|
||||
if (style == MediaStyle.Drama) {
|
||||
Get.to(() => DramaDetailPage(
|
||||
drama: DramaMediaInfo()
|
||||
..id = videoModel.id
|
||||
..title = videoModel.title
|
||||
..verticalCover = videoModel.cover,
|
||||
initialContentId: videoModel.subid,
|
||||
));
|
||||
return;
|
||||
}
|
||||
//id 为 -1 的是老版本落的记录,没有详情可拉,只能本地放
|
||||
if (videoModel.id == "-1") {
|
||||
_playLocal(videoModel, videoModel.title ?? "");
|
||||
return;
|
||||
}
|
||||
pushToVideoPage(
|
||||
videoModel: videoModel, isCartoon: videoModel.videoType == 1);
|
||||
}
|
||||
|
||||
/// 用本地文件播([SimpleVideoPlayerLogic] 拿到 localPath 就走 PlayerFactory.file)
|
||||
void _playLocal(VideoModel videoModel, String title) =>
|
||||
Get.to(SimpleVideoPlayerPage(
|
||||
localPath: videoModel.localPath,
|
||||
videoUrl: videoModel.realVideoUrl,
|
||||
title: title,
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user