初始化

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,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 在列表里找到对应 modelurl 带 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,
));
}