初始化
This commit is contained in:
Vendored
+151
@@ -0,0 +1,151 @@
|
||||
// ignore_for_file: unnecessary_null_comparison, unused_element
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart'
|
||||
show DefaultCacheManager;
|
||||
import 'package:hgdj/tools_base/image/image_data_handle/image_cache_disk.dart';
|
||||
import 'package:hgdj/tools_base/image/image_data_handle/image_manager.dart';
|
||||
import 'package:hgdj/tools_base/video_download/video_download_manager.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../hj_utils/file_util.dart';
|
||||
import '../../hj_utils/video_cache_manager.dart';
|
||||
import 'image_cache_manager.dart';
|
||||
|
||||
///加载缓存 统计缓存大小
|
||||
Future<String> loadCache() async {
|
||||
double total = 0;
|
||||
Directory videoCacheDir =
|
||||
Directory(await VideoCacheManager().getFilePath() ?? '');
|
||||
if (await videoCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(videoCacheDir);
|
||||
}
|
||||
Directory videoLoadedDir =
|
||||
Directory(await VideoDownloadManager.instance.cacheDir());
|
||||
if (await videoLoadedDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(videoLoadedDir);
|
||||
}
|
||||
|
||||
// 图片磁盘缓存(ImageCacheDisk)
|
||||
Directory imageDiskCacheDir = Directory(await ImageCacheDisk.findSavePath());
|
||||
if (await imageDiskCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(imageDiskCacheDir);
|
||||
}
|
||||
|
||||
// CachedNetworkImage 缓存(ImageCacheManager)
|
||||
Directory imageCacheDir = Directory(await ImageCacheManager().getFilePath());
|
||||
if (await imageCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(imageCacheDir);
|
||||
}
|
||||
|
||||
// NetworkImageLoader 在 encrypt:false 时(直播封面)走 DefaultCacheManager,目录单独统计
|
||||
Directory defaultImageCacheDir = Directory(await _defaultImageCacheDirPath());
|
||||
if (await defaultImageCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(defaultImageCacheDir);
|
||||
}
|
||||
|
||||
return FileUtil.byteFmt(total.toInt());
|
||||
}
|
||||
|
||||
/// DefaultCacheManager 的磁盘目录:临时目录下的 'libCachedImageData'(flutter_cache_manager 默认 key)
|
||||
Future<String> _defaultImageCacheDirPath() async {
|
||||
final dir = await getTemporaryDirectory();
|
||||
return path.join(dir.path, 'libCachedImageData');
|
||||
}
|
||||
|
||||
Future<double> _getTotalSizeOfFilesInDir(final FileSystemEntity file) async {
|
||||
try {
|
||||
if (file is File) {
|
||||
return (await file.length()).toDouble();
|
||||
}
|
||||
if (file is Directory && await file.exists()) {
|
||||
double total = 0;
|
||||
// recursive 已一次性列出所有后代文件,只累加 File 即可;不再对子目录二次递归,
|
||||
// 避免同一文件被重复计入导致缓存大小翻倍。异步 list 替代 listSync,避免大目录阻塞 UI。
|
||||
await for (final child
|
||||
in file.list(recursive: true, followLinks: false)) {
|
||||
if (child is File) {
|
||||
total += (await child.length()).toDouble();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 手动清理缓存
|
||||
Future handleClearAllCache() async {
|
||||
await VideoDownloadManager.instance.emptyCache();
|
||||
await ImageCacheDisk.emptyCache();
|
||||
await VideoCacheManager().emptyCache();
|
||||
await ImageCacheManager().emptyCache();
|
||||
await DefaultCacheManager().emptyCache(); // 直播封面(encrypt:false)走的默认缓存
|
||||
}
|
||||
|
||||
/// 清除缓存如果必要
|
||||
/// [force] 强至清除
|
||||
Future clearCacheIfNeed({bool force = false, bool isHandle = false}) async {
|
||||
if (force) {
|
||||
await VideoCacheManager().emptyCache();
|
||||
// await VideoSubCacheManager().emptyCache();
|
||||
// CachedVideoStore().clean();
|
||||
await ImageCacheManager().emptyCache();
|
||||
if (isHandle) {
|
||||
//await VideoDownloadManager.instance.emptyCache();
|
||||
// await ImageCacheDisk.emptyCache();
|
||||
}
|
||||
debugPrint(
|
||||
" ------------------ VideoCacheManager ImageCacheManager clear completely");
|
||||
} else {
|
||||
double total = 0;
|
||||
Directory videoCacheDir =
|
||||
Directory(await VideoCacheManager().getFilePath() ?? '');
|
||||
if (await videoCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(videoCacheDir);
|
||||
}
|
||||
//ts流最大2M,应该是>1000个ts流
|
||||
if (total > 500 * MB_SIZE) {
|
||||
await VideoCacheManager().emptyCache();
|
||||
}
|
||||
total = 0;
|
||||
Directory imageCacheDir =
|
||||
Directory(await ImageCacheManager().getFilePath());
|
||||
if (await imageCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(imageCacheDir);
|
||||
}
|
||||
//按照100Kb一张图片的化,估计是5000张网络图片
|
||||
if (total > 500 * MB_SIZE) {
|
||||
await ImageCacheManager().emptyCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearAllCache(bool isHandle) async {
|
||||
//await clearCacheIfNeed(force: true,isHandle: isHandle);
|
||||
|
||||
ImageManager.instance.clearBySize(maxSize: 1000);
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
|
||||
debugPrint(
|
||||
" ----------- PaintingBinding.instance.imageCache.clearLiveImages()");
|
||||
}
|
||||
|
||||
///递归方式删除目录
|
||||
Future<Null> delDir(FileSystemEntity file) async {
|
||||
try {
|
||||
if (file is Directory) {
|
||||
final List<FileSystemEntity> children = file.listSync();
|
||||
for (final FileSystemEntity child in children) {
|
||||
await delDir(child);
|
||||
}
|
||||
}
|
||||
await file.delete();
|
||||
} catch (e) {}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../hj_utils/text_util.dart';
|
||||
|
||||
/// 取消token管理
|
||||
class CancelTokenManager {
|
||||
static CancelTokenManager? _instance;
|
||||
//取消token单例
|
||||
factory CancelTokenManager() {
|
||||
_instance ??= CancelTokenManager._();
|
||||
return _instance!;
|
||||
}
|
||||
CancelTokenManager._();
|
||||
|
||||
/// 取消token列表
|
||||
final List<CancelTokenWarp> _cancelTokens = [];
|
||||
|
||||
/// 创建cancleToken如果需要
|
||||
/// [url]
|
||||
CancelToken createToken(String url, [String uniquekey='']) {
|
||||
var ctw = CancelTokenWarp(url, CancelToken())..uniqueKey = uniquekey;
|
||||
_cancelTokens.add(ctw);
|
||||
return ctw.token;
|
||||
}
|
||||
|
||||
/// 获取cancleToken如果需要
|
||||
/// [url]
|
||||
CancelToken? getToken(String url, [String uniquekey = '']) {
|
||||
// indexWhere 找不到返回 -1,避免 firstWhere 无 orElse 时抛 StateError
|
||||
final idx = _cancelTokens.indexWhere((it) =>
|
||||
TextUtil.isEmpty(uniquekey) ? it.url == url : (it.url == url && it.uniqueKey == uniquekey));
|
||||
return idx < 0 ? null : _cancelTokens[idx].token;
|
||||
}
|
||||
|
||||
List<CancelTokenWarp> get peekList => _cancelTokens;
|
||||
|
||||
/// 删除
|
||||
/// [url]
|
||||
CancelToken? remove(String url, [String uniquekey = '']) {
|
||||
// indexWhere 找不到返回 -1,避免 firstWhere 无 orElse 时抛 StateError
|
||||
final idx = _cancelTokens.indexWhere((it) =>
|
||||
TextUtil.isEmpty(uniquekey) ? it.url == url : (it.url == url && it.uniqueKey == uniquekey));
|
||||
if (idx < 0) return null;
|
||||
return _cancelTokens.removeAt(idx).token;
|
||||
}
|
||||
|
||||
/// 正在请求的长度
|
||||
int get length => _cancelTokens.length;
|
||||
}
|
||||
|
||||
class CancelTokenWarp {
|
||||
String url;
|
||||
CancelToken token;
|
||||
String? uniqueKey;
|
||||
CancelTokenWarp(this.url, this.token);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:http/http.dart' hide Response;
|
||||
|
||||
import '../../extension/extensions.dart';
|
||||
import '../../hj_utils/file_util.dart';
|
||||
import '../net/load_apk/dio_cli.dart';
|
||||
import 'cancel_token_manager.dart';
|
||||
|
||||
/// dio 文件下载器(视频流缓存的 FileService)
|
||||
class DioFileService extends FileService {
|
||||
/// 真正请求的 dio
|
||||
final _dio = createDio();
|
||||
|
||||
@override
|
||||
Future<FileServiceResponse> get(String url,
|
||||
{Map<String, String>? headers = const {}}) async {
|
||||
// cancelToken 用文件名注册、下载结束后按同一 key 移除
|
||||
//(原来 createToken 用 name 而 remove 用 url,key 不一致:既移不掉导致泄漏,又会 firstWhere 抛 StateError)
|
||||
final name = FileUtil.getName(url);
|
||||
final token = CancelTokenManager().createToken(name);
|
||||
|
||||
// 每次请求用独立 Options:本类随 VideoCacheManager 单例,共享一份 Options 会被并发请求互相覆盖 headers
|
||||
final options = Options(
|
||||
method: 'GET',
|
||||
sendTimeout: const Duration(milliseconds: 5000),
|
||||
receiveTimeout: const Duration(milliseconds: 10000),
|
||||
headers: {...?headers, 'cache-control': 'max-age=31104000'},
|
||||
contentType: ContentType.binary.toString(),
|
||||
responseType: ResponseType.stream,
|
||||
);
|
||||
|
||||
Response<ResponseBody>? resp;
|
||||
try {
|
||||
debugLog("DioFileService get: $url");
|
||||
resp = await _dio.get<ResponseBody>(url,
|
||||
cancelToken: token, options: options);
|
||||
} catch (e) {
|
||||
debugLog("DioFileService get error: $url -> $e");
|
||||
} finally {
|
||||
CancelTokenManager().remove(name);
|
||||
}
|
||||
|
||||
// 只认 200/206;其余(含请求异常 resp==null)直接抛:避免返回空流挂起、坏响应被缓存
|
||||
final statusCode = resp?.statusCode ?? HttpStatus.badRequest;
|
||||
if (resp?.data == null || (statusCode != 200 && statusCode != 206)) {
|
||||
throw HttpException("video chunk fetch failed ($statusCode): $url");
|
||||
}
|
||||
|
||||
final respHeaders = <String, String>{};
|
||||
resp!.headers.forEach((key, values) {
|
||||
if (values.notEmpty()) respHeaders[key] = values.first;
|
||||
});
|
||||
// Content-Length 可能缺失/非数字,安全解析(原 contentLengthS[0] 在空串时会 RangeError);缺失传 null 表示长度未知
|
||||
final contentLength =
|
||||
int.tryParse(respHeaders[Headers.contentLengthHeader] ?? '');
|
||||
|
||||
debugLog(
|
||||
"DioFileService resp($statusCode): $url contentLength:$contentLength");
|
||||
|
||||
return HttpGetResponse(StreamedResponse(
|
||||
resp.data!.stream,
|
||||
statusCode,
|
||||
headers: respHeaders,
|
||||
contentLength: contentLength,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
/// 浏览历史本地存储基类(sqflite 持久化)
|
||||
/// 子类需声明 [dbName] 和 [tableName],每种业务类型独占一个 db 文件
|
||||
abstract class BaseHistoryRecordStore {
|
||||
/// 子类提供:db 文件名(如 video_history.db)
|
||||
String get dbName;
|
||||
|
||||
/// 子类提供:表名(如 video_history)
|
||||
String get tableName;
|
||||
|
||||
/// 同一类型保留最大条数(超过后插入时自动删最旧)
|
||||
static const int maxRows = 1000;
|
||||
|
||||
Database? _db;
|
||||
|
||||
Future<Database> _openDB() async {
|
||||
if (_db != null) return _db!;
|
||||
final dir = await getDatabasesPath();
|
||||
final path = p.join(dir, dbName);
|
||||
_db = await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: (db, v) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS $tableName(
|
||||
model_id TEXT PRIMARY KEY,
|
||||
create_time INTEGER NOT NULL,
|
||||
model_data TEXT NOT NULL
|
||||
);
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_${tableName}_time ON $tableName(create_time DESC)',
|
||||
);
|
||||
},
|
||||
);
|
||||
return _db!;
|
||||
}
|
||||
|
||||
/// 保存记录(model_id 主键冲突时 REPLACE 实现去重,自动刷新 create_time = "插到最前")
|
||||
Future<bool> save({
|
||||
required String modelId,
|
||||
required String modelDataJson,
|
||||
}) async {
|
||||
if (modelId.isEmpty) return false;
|
||||
final db = await _openDB();
|
||||
await db.insert(
|
||||
tableName,
|
||||
{
|
||||
'model_id': modelId,
|
||||
'create_time': DateTime.now().millisecondsSinceEpoch,
|
||||
'model_data': modelDataJson,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await _trimOverflow(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 分页查询(按创建时间倒序),返回 model_data JSON 字符串列表
|
||||
Future<List<String>> fetch({
|
||||
int page = 1,
|
||||
int pageSize = maxRows,
|
||||
}) async {
|
||||
final db = await _openDB();
|
||||
final offset = ((page - 1) < 0 ? 0 : (page - 1)) * pageSize;
|
||||
final rows = await db.query(
|
||||
tableName,
|
||||
columns: ['model_data'],
|
||||
orderBy: 'create_time DESC',
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map((r) => r['model_data'] as String).toList();
|
||||
}
|
||||
|
||||
/// 删除单条
|
||||
Future<bool> remove(String modelId) async {
|
||||
if (modelId.isEmpty) return false;
|
||||
final db = await _openDB();
|
||||
await db.delete(tableName, where: 'model_id = ?', whereArgs: [modelId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 清空当前类型的所有记录
|
||||
Future<void> clean() async {
|
||||
final db = await _openDB();
|
||||
await db.delete(tableName);
|
||||
}
|
||||
|
||||
/// 超过 maxRows 时删除最旧的若干条
|
||||
Future<void> _trimOverflow(Database db) async {
|
||||
final cntRow = await db.rawQuery('SELECT COUNT(*) AS cnt FROM $tableName');
|
||||
final cnt = Sqflite.firstIntValue(cntRow) ?? 0;
|
||||
if (cnt <= maxRows) return;
|
||||
final excess = cnt - maxRows;
|
||||
await db.execute(
|
||||
'DELETE FROM $tableName WHERE rowid IN ('
|
||||
'SELECT rowid FROM $tableName ORDER BY create_time ASC LIMIT ?)',
|
||||
[excess],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'base_history_record_store.dart';
|
||||
|
||||
/// 卡通浏览历史(MediaStyle.Cartoon)
|
||||
class CartoonHistoryStore extends BaseHistoryRecordStore {
|
||||
static CartoonHistoryStore? _instance;
|
||||
factory CartoonHistoryStore() => _instance ??= CartoonHistoryStore._();
|
||||
CartoonHistoryStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'cartoon_history.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'cartoon_history';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'base_history_record_store.dart';
|
||||
|
||||
/// 漫画浏览历史(MediaStyle.Comics)
|
||||
class ComicsHistoryStore extends BaseHistoryRecordStore {
|
||||
static ComicsHistoryStore? _instance;
|
||||
factory ComicsHistoryStore() => _instance ??= ComicsHistoryStore._();
|
||||
ComicsHistoryStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'comics_history.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'comics_history';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'base_history_record_store.dart';
|
||||
|
||||
/// 社区帖子浏览历史(MediaStyle.Community)
|
||||
class CommunityHistoryStore extends BaseHistoryRecordStore {
|
||||
static CommunityHistoryStore? _instance;
|
||||
factory CommunityHistoryStore() => _instance ??= CommunityHistoryStore._();
|
||||
CommunityHistoryStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'community_history.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'community_history';
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hgdj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgdj/hj_model/drama_media_info.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/base_history_record_store.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
|
||||
/// 短剧观看记录:纯本地,不依赖后端。一部剧一行(看到哪一集、第几秒 + 剧信息快照),
|
||||
/// 最多留 [BaseHistoryRecordStore.maxRows] 条,超了先淘汰最久没看的那部。
|
||||
/// 它同时是「续播位置」和「历史记录」两件事的数据源——每次写入都会刷新 create_time,
|
||||
/// 所以 [history] 按 create_time 倒序拿到的就是最近观看列表,不用再建第二张表
|
||||
class DramaResumeStore extends BaseHistoryRecordStore {
|
||||
static final DramaResumeStore _instance = DramaResumeStore._();
|
||||
|
||||
factory DramaResumeStore() => _instance;
|
||||
|
||||
DramaResumeStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'drama_resume.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'drama_resume';
|
||||
|
||||
//读盘一次,之后 of() 都走内存(它是同步的,定位续播集时没法等 IO)
|
||||
Map<String, DramaResume>? _cache;
|
||||
|
||||
//并发挡板:Feed 和二级页会同时调 load(),没这层的话后到的直接返回,
|
||||
//拿着还没填好的表去定位,续播位置就白丢了
|
||||
Future<void>? _loading;
|
||||
|
||||
/// 全表读进内存。进短剧频道/二级页前调一次即可,重复调只读一次
|
||||
Future<void> load() => _loading ??= _load();
|
||||
|
||||
Future<void> _load() async {
|
||||
final disk = <String, DramaResume>{};
|
||||
try {
|
||||
//反着插:Map 保留插入顺序,最旧的排在队头,超量从队头砍,队尾就是最近看的
|
||||
for (final item in (await _readAllDesc()).reversed) {
|
||||
disk[item.mediaId] = item;
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog('短剧续播记录读取失败', e.toString()); // 坏数据直接当没有,别把页面带崩
|
||||
}
|
||||
//读盘期间已经看起来的那几条比盘上的新:后放,既覆盖旧值又排到队尾
|
||||
final live = _cache;
|
||||
_cache = disk;
|
||||
live?.forEach(_put);
|
||||
}
|
||||
|
||||
/// 写内存表:先删再插把它挪到队尾(=最近观看),超 [BaseHistoryRecordStore.maxRows] 从队头砍。
|
||||
/// 淘汰口径要和 db 的 create_time 对齐,否则内存里会留着盘上已经没有的剧
|
||||
void _put(String mediaId, DramaResume item) {
|
||||
final cache = _cache ??= {};
|
||||
cache
|
||||
..remove(mediaId)
|
||||
..[mediaId] = item;
|
||||
while (cache.length > BaseHistoryRecordStore.maxRows) {
|
||||
cache.remove(cache.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
/// 这部剧上次看到哪:没看过返回 null
|
||||
DramaResume? of(String? mediaId) => mediaId == null ? null : _cache?[mediaId];
|
||||
|
||||
/// 读全表并按「最后观看时间」倒序。老数据没有 watchedAt(全是 0),同值时退回 db 给的
|
||||
/// create_time 顺序,别让升级前的那批互相乱掉——List.sort 不保证稳定,所以拿下标当次序键
|
||||
Future<List<DramaResume>> _readAllDesc() async {
|
||||
final rows = await fetch(); // db 那层给的就是 create_time 倒序
|
||||
final indexed = <(int, DramaResume)>[];
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
final item = DramaResume.fromJson(jsonDecode(rows[i]));
|
||||
if (item.mediaId.isNotEmpty) indexed.add((i, item));
|
||||
}
|
||||
indexed.sort((a, b) {
|
||||
final byTime = b.$2.watchedAt.compareTo(a.$2.watchedAt);
|
||||
return byTime != 0 ? byTime : a.$1.compareTo(b.$1);
|
||||
});
|
||||
return indexed.map((e) => e.$2).toList();
|
||||
}
|
||||
|
||||
/// 观看历史:按最后观看时间倒序分页,不走 db 分页——排序键是记录里的 watchedAt,
|
||||
/// 本地表统共上限 1000 条,整表读出来再切页就够,也免得把内存表里的剧信息快照交出去被改
|
||||
Future<List<DramaResume>> history({int page = 1, int pageSize = 20}) async {
|
||||
try {
|
||||
final all = (await _readAllDesc()).where((e) => e.drama != null).toList();
|
||||
final start = (page < 1 ? 0 : page - 1) * pageSize;
|
||||
if (start >= all.length) return [];
|
||||
final end = start + pageSize;
|
||||
return all.sublist(start, end > all.length ? all.length : end);
|
||||
} catch (e) {
|
||||
debugLog('短剧观看记录读取失败', e.toString());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 删一条(历史记录页编辑态用)。内存表要一起删,否则 [of] 还会把它当续播位置返回
|
||||
Future<void> erase(String? mediaId) async {
|
||||
if (mediaId == null || mediaId.isEmpty) return;
|
||||
_cache?.remove(mediaId);
|
||||
await remove(mediaId);
|
||||
}
|
||||
|
||||
/// 刷剧模式划到这部剧:每次都把观看时间刷成现在(历史记录按它倒序,最后刷到的排最前),
|
||||
/// 但进度一律不动——已有记录就原样沿用二级页记下的集数+秒数,没有才新建一条 0 秒的
|
||||
Future<void> markWatched({
|
||||
required String? mediaId,
|
||||
required String? contentId,
|
||||
DramaMediaInfo? drama,
|
||||
}) async {
|
||||
if (mediaId == null || mediaId.isEmpty) return;
|
||||
await load(); // 盘还没读完就取,会把已有进度当成没有,反手写一条 0 秒的盖掉
|
||||
final last = _cache?[mediaId];
|
||||
record(
|
||||
mediaId: mediaId,
|
||||
//空 contentId 会被 record 挡掉,那样这次刷新就白记了,退回当前这一集
|
||||
contentId:
|
||||
last?.contentId.isNotEmpty == true ? last!.contentId : contentId,
|
||||
seconds: last?.progressSeconds ?? 0,
|
||||
drama: drama ?? last?.drama,
|
||||
);
|
||||
}
|
||||
|
||||
/// 记一次进度。[seconds] 传 0 表示从头看(本集刚播完时用)。
|
||||
/// [drama] 是列表展示用的剧信息快照,不传就沿用这部剧上一条记录里的,别把它写没了
|
||||
void record({
|
||||
required String? mediaId,
|
||||
required String? contentId,
|
||||
required int seconds,
|
||||
DramaMediaInfo? drama,
|
||||
}) {
|
||||
//空串和 null 一样要挡:contentId 为空的记录谁都匹配不上,
|
||||
//却会把这部剧上一条好记录顶掉,还会让 _resumeIndex 白翻完整部剧的分页
|
||||
if (mediaId == null ||
|
||||
mediaId.isEmpty ||
|
||||
contentId == null ||
|
||||
contentId.isEmpty) return;
|
||||
final item = DramaResume(
|
||||
mediaId: mediaId,
|
||||
contentId: contentId,
|
||||
progressSeconds: seconds < 0 ? 0 : seconds,
|
||||
drama: drama ?? _cache?[mediaId]?.drama,
|
||||
watchedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
_put(mediaId, item);
|
||||
//不等落盘:主键冲突走 REPLACE,顺带把 create_time 刷成本次时间,超量时最久没看的先淘汰。
|
||||
//必须接住异常,否则 db 出错会变成没人管的 async error
|
||||
save(modelId: mediaId, modelDataJson: jsonEncode(item.toJson()))
|
||||
.catchError((e) {
|
||||
debugLog('短剧续播记录写入失败', e.toString());
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 全站唯一实例
|
||||
final dramaResume = DramaResumeStore();
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'base_history_record_store.dart';
|
||||
|
||||
/// 黄游浏览历史(MediaStyle.Game)
|
||||
class GameHistoryStore extends BaseHistoryRecordStore {
|
||||
static GameHistoryStore? _instance;
|
||||
factory GameHistoryStore() => _instance ??= GameHistoryStore._();
|
||||
GameHistoryStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'game_history.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'game_history';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'base_history_record_store.dart';
|
||||
|
||||
/// 小说浏览历史(MediaStyle.Novel)
|
||||
class NovelHistoryStore extends BaseHistoryRecordStore {
|
||||
static NovelHistoryStore? _instance;
|
||||
factory NovelHistoryStore() => _instance ??= NovelHistoryStore._();
|
||||
NovelHistoryStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'novel_history.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'novel_history';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'base_history_record_store.dart';
|
||||
|
||||
/// 图集浏览历史(MediaStyle.Pic)
|
||||
class PicHistoryStore extends BaseHistoryRecordStore {
|
||||
static PicHistoryStore? _instance;
|
||||
factory PicHistoryStore() => _instance ??= PicHistoryStore._();
|
||||
PicHistoryStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'pic_history.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'pic_history';
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
/// 搜索历史本地存储(sqflite 持久化)
|
||||
/// keyword 作为主键去重:重复搜索 REPLACE 刷新时间,即「提到最前」
|
||||
class SearchHistoryStore {
|
||||
static SearchHistoryStore? _instance;
|
||||
factory SearchHistoryStore() => _instance ??= SearchHistoryStore._();
|
||||
SearchHistoryStore._();
|
||||
|
||||
static const String _dbName = 'search_history.db';
|
||||
static const String _tableName = 'search_history';
|
||||
|
||||
/// 最多保留条数,超过后插入时自动删最旧
|
||||
static const int maxRows = 10;
|
||||
|
||||
Database? _db;
|
||||
|
||||
Future<Database> _openDB() async {
|
||||
if (_db != null) return _db!;
|
||||
final dir = await getDatabasesPath();
|
||||
final path = p.join(dir, _dbName);
|
||||
_db = await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: (db, v) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS $_tableName(
|
||||
keyword TEXT PRIMARY KEY,
|
||||
create_time INTEGER NOT NULL
|
||||
);
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_${_tableName}_time ON $_tableName(create_time DESC)',
|
||||
);
|
||||
},
|
||||
);
|
||||
return _db!;
|
||||
}
|
||||
|
||||
/// 新增一条搜索词(主键冲突 REPLACE 实现去重并刷新时间 = 提到最前)
|
||||
Future<void> save(String keyword) async {
|
||||
if (keyword.isEmpty) return;
|
||||
final db = await _openDB();
|
||||
await db.insert(
|
||||
_tableName,
|
||||
{'keyword': keyword, 'create_time': DateTime.now().millisecondsSinceEpoch},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await _trimOverflow(db);
|
||||
}
|
||||
|
||||
/// 查询全部搜索词(按时间倒序)
|
||||
Future<List<String>> fetchAll() async {
|
||||
final db = await _openDB();
|
||||
final rows = await db.query(
|
||||
_tableName,
|
||||
columns: ['keyword'],
|
||||
orderBy: 'create_time DESC',
|
||||
);
|
||||
return rows.map((r) => r['keyword'] as String).toList();
|
||||
}
|
||||
|
||||
/// 清空全部搜索历史
|
||||
Future<void> clean() async {
|
||||
final db = await _openDB();
|
||||
await db.delete(_tableName);
|
||||
}
|
||||
|
||||
/// 超过 [maxRows] 时删除最旧的若干条
|
||||
Future<void> _trimOverflow(Database db) async {
|
||||
final cntRow = await db.rawQuery('SELECT COUNT(*) AS cnt FROM $_tableName');
|
||||
final cnt = Sqflite.firstIntValue(cntRow) ?? 0;
|
||||
if (cnt <= maxRows) return;
|
||||
await db.execute(
|
||||
'DELETE FROM $_tableName WHERE rowid IN ('
|
||||
'SELECT rowid FROM $_tableName ORDER BY create_time ASC LIMIT ?)',
|
||||
[cnt - maxRows],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'base_history_record_store.dart';
|
||||
|
||||
/// 短视频浏览历史(MediaStyle.ShortVideo)
|
||||
class ShortVideoHistoryStore extends BaseHistoryRecordStore {
|
||||
static ShortVideoHistoryStore? _instance;
|
||||
factory ShortVideoHistoryStore() => _instance ??= ShortVideoHistoryStore._();
|
||||
ShortVideoHistoryStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'short_video_history.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'short_video_history';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'base_history_record_store.dart';
|
||||
|
||||
/// 视频浏览历史(MediaStyle.Video)
|
||||
class VideoHistoryStore extends BaseHistoryRecordStore {
|
||||
static VideoHistoryStore? _instance;
|
||||
factory VideoHistoryStore() => _instance ??= VideoHistoryStore._();
|
||||
VideoHistoryStore._();
|
||||
|
||||
@override
|
||||
String get dbName => 'video_history.db';
|
||||
|
||||
@override
|
||||
String get tableName => 'video_history';
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../config/address.dart';
|
||||
import '../../extension/extensions.dart';
|
||||
import '../debug_log.dart';
|
||||
import '../image/image_data_handle/image_crypto.dart';
|
||||
import '../net/load_apk/dio_cli.dart';
|
||||
|
||||
class ImageCacheManager extends CacheManager {
|
||||
static const image_key = "customCache";
|
||||
|
||||
// 工厂模式
|
||||
factory ImageCacheManager() => _getInstance();
|
||||
|
||||
static ImageCacheManager get instance => _getInstance();
|
||||
static ImageCacheManager? _instance;
|
||||
|
||||
static ImageCacheManager _getInstance() {
|
||||
_instance ??= ImageCacheManager._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
ImageCacheManager._internal()
|
||||
: super(Config(image_key, maxNrOfCacheObjects: 400, stalePeriod: const Duration(days: 7), fileService: CustomFileRespons()));
|
||||
|
||||
Future<String> getFilePath() async {
|
||||
var directory = await getTemporaryDirectory();
|
||||
return path.join(directory.path, image_key);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomFileRespons extends HttpFileService {
|
||||
DioCli client = DioCli(
|
||||
options: BaseOptions(
|
||||
connectTimeout: const Duration(milliseconds: 30000),
|
||||
receiveTimeout: const Duration(milliseconds: 30000),
|
||||
sendTimeout: const Duration(milliseconds: 30000),
|
||||
validateStatus: (int? status) {
|
||||
if (status != null) return status < 600;
|
||||
return false;
|
||||
}));
|
||||
|
||||
@override
|
||||
Future<FileServiceResponse> get(String url, {Map<String, String>? headers = const {}}) async {
|
||||
if (!url.startsWith("http") && !url.startsWith("https")) {
|
||||
url = path.join(Address.baseImagePath ?? '', url);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugLog("image_request", "get()...begin...ulr:$url...");
|
||||
}
|
||||
|
||||
headers?['cache-control'] = 'max-age=31104000';
|
||||
|
||||
final resp = await client.getBytes(url, headers: headers);
|
||||
final statusCode = resp.data?.statusCode ?? HttpStatus.badRequest;
|
||||
|
||||
// 失败 / 非 2xx:直接抛。避免错误响应(空内容、CDN 错误页)被落盘缓存成坏图,
|
||||
// 让 CachedNetworkImage 走 errorWidget;下次能重新请求,而不是一直命中坏缓存。
|
||||
if (resp.err != null || statusCode < 200 || statusCode >= 300) {
|
||||
if (kDebugMode) {
|
||||
debugLog("image_request", "get()...failed...$url...code:($statusCode): ${resp.err}");
|
||||
}
|
||||
throw HttpException("image fetch failed ($statusCode): ${resp.err}", uri: Uri.tryParse(url));
|
||||
}
|
||||
|
||||
// 解密(Dio 不同版本 bytes 返回 List<int> 或 Uint8List)。解密失败 / 空字节同样抛出,不缓存坏图。
|
||||
Uint8List bytes;
|
||||
try {
|
||||
final raw = resp.data?.data;
|
||||
final decrypted = raw == null ? null : ImageCrypto.decryptImage(raw is Uint8List ? raw : Uint8List.fromList(raw));
|
||||
if (decrypted == null || decrypted.isEmpty) {
|
||||
throw const FormatException("empty image bytes");
|
||||
}
|
||||
//部分 CDN 返回的加密 JPEG 缺末尾 EOI(FF D9),Skia 严格 decoder 会拒;补 EOI 让标准 decoder 也能解
|
||||
bytes = _repairJpegEoiIfNeeded(decrypted);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugLog("ImageCacheManager", "decrypt failed: $url -> $e");
|
||||
throw HttpException("image decrypt failed: $e", uri: Uri.tryParse(url));
|
||||
}
|
||||
|
||||
final respHeaders = <String, String>{};
|
||||
resp.data?.headers.forEach((key, arrayValue) {
|
||||
if (arrayValue.notEmpty()) respHeaders[key] = arrayValue[0];
|
||||
});
|
||||
|
||||
if (kDebugMode) {
|
||||
debugLog("image_request", "get()...success...$url...code:($statusCode): contentLength:${bytes.length}");
|
||||
}
|
||||
|
||||
return HttpGetResponse(StreamedResponse(
|
||||
Stream.value(bytes),
|
||||
statusCode,
|
||||
contentLength: bytes.length, // 补 EOI 后用真实长度
|
||||
headers: respHeaders,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
//判断是 jpeg 头但无 FF D9 结尾时补 EOI;其他格式 / 已完整 jpeg 原样返回
|
||||
Uint8List _repairJpegEoiIfNeeded(Uint8List bytes) {
|
||||
if (bytes.length < 4) return bytes;
|
||||
final isJpeg = bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||
if (!isJpeg) return bytes;
|
||||
final hasEoi = bytes[bytes.length - 2] == 0xFF && bytes[bytes.length - 1] == 0xD9;
|
||||
if (hasEoi) return bytes;
|
||||
final fixed = Uint8List(bytes.length + 2)
|
||||
..setRange(0, bytes.length, bytes)
|
||||
..[bytes.length] = 0xFF
|
||||
..[bytes.length + 1] = 0xD9;
|
||||
return fixed;
|
||||
}
|
||||
Reference in New Issue
Block a user