初始化
This commit is contained in:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user