feat(storage): 实现跨平台 Storage API 抽象层

- 添加统一的 IStorage 接口定义
- 实现 IndexedDBStorage (Web/Desktop)
- 实现 NativeStorage (Android/TV)
- 添加类型定义 (Subscription, Channel, SourceValidity等)
- 更新 Android/TV AssetReader 支持 SharedPreferences
- 安装 idb 库用于 IndexedDB 操作
This commit is contained in:
李岩岩 2026-02-05 14:29:42 +08:00
parent 2cab50db31
commit 380f4ab4d6
7 changed files with 621 additions and 1 deletions

View File

@ -1,6 +1,7 @@
package com.iptv.tv; package com.iptv.tv;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.webkit.JavascriptInterface; import android.webkit.JavascriptInterface;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
@ -9,9 +10,12 @@ import java.io.InputStreamReader;
public class AssetReader { public class AssetReader {
private Context context; private Context context;
private SharedPreferences prefs;
private static final String PREFS_NAME = "IPTVData";
public AssetReader(Context context) { public AssetReader(Context context) {
this.context = context; this.context = context;
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
} }
@JavascriptInterface @JavascriptInterface
@ -45,4 +49,31 @@ public class AssetReader {
return false; return false;
} }
} }
// SharedPreferences 存储接口
@JavascriptInterface
public String getItem(String key) {
return prefs.getString(key, null);
}
@JavascriptInterface
public void setItem(String key, String value) {
SharedPreferences.Editor editor = prefs.edit();
editor.putString(key, value);
editor.apply();
}
@JavascriptInterface
public void removeItem(String key) {
SharedPreferences.Editor editor = prefs.edit();
editor.remove(key);
editor.apply();
}
@JavascriptInterface
public void clear() {
SharedPreferences.Editor editor = prefs.edit();
editor.clear();
editor.apply();
}
} }

View File

@ -1,6 +1,7 @@
package com.iptv.app; package com.iptv.app;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.webkit.JavascriptInterface; import android.webkit.JavascriptInterface;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
@ -9,15 +10,17 @@ import java.io.InputStreamReader;
public class AssetReader { public class AssetReader {
private Context context; private Context context;
private SharedPreferences prefs;
private static final String PREFS_NAME = "IPTVData";
public AssetReader(Context context) { public AssetReader(Context context) {
this.context = context; this.context = context;
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
} }
@JavascriptInterface @JavascriptInterface
public String readFile(String path) { public String readFile(String path) {
try { try {
// path : "www/api/result.txt"
InputStream is = context.getAssets().open(path); InputStream is = context.getAssets().open(path);
BufferedReader reader = new BufferedReader(new InputStreamReader(is)); BufferedReader reader = new BufferedReader(new InputStreamReader(is));
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
@ -46,4 +49,31 @@ public class AssetReader {
return false; return false;
} }
} }
// SharedPreferences 存储接口
@JavascriptInterface
public String getItem(String key) {
return prefs.getString(key, null);
}
@JavascriptInterface
public void setItem(String key, String value) {
SharedPreferences.Editor editor = prefs.edit();
editor.putString(key, value);
editor.apply();
}
@JavascriptInterface
public void removeItem(String key) {
SharedPreferences.Editor editor = prefs.edit();
editor.remove(key);
editor.apply();
}
@JavascriptInterface
public void clear() {
SharedPreferences.Editor editor = prefs.edit();
editor.clear();
editor.apply();
}
} }

11
ui/package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@vueuse/core": "^14.2.0", "@vueuse/core": "^14.2.0",
"hls.js": "^1.5.0", "hls.js": "^1.5.0",
"idb": "^8.0.3",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"
@ -351,6 +352,11 @@
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
}, },
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="
},
"node_modules/is-what": { "node_modules/is-what": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
@ -845,6 +851,11 @@
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
}, },
"idb": {
"version": "8.0.3",
"resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="
},
"is-what": { "is-what": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",

View File

@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@vueuse/core": "^14.2.0", "@vueuse/core": "^14.2.0",
"hls.js": "^1.5.0", "hls.js": "^1.5.0",
"idb": "^8.0.3",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"

View File

@ -0,0 +1,8 @@
# Storage Adapters
存储适配器目录,按平台分离实现:
- `indexeddb.js` - Web/Desktop 端(使用 IndexedDB
- `native.js` - Android/TV 端(通过 JS Bridge 调用原生存储)
目前所有实现统一在 `../index.js` 中,如需拆分可在此目录创建单独文件。

467
ui/src/storage/index.js Normal file
View File

@ -0,0 +1,467 @@
import { openDB } from 'idb';
import {
Subscription, Channel, SourceValidity,
Preferences, PlayHistory, CacheMeta
} from './types.js';
const DB_NAME = 'IPTVStorage';
const DB_VERSION = 1;
/**
* 抽象存储接口
*/
class IStorage {
// 基础操作
async get(key) { throw new Error('Not implemented'); }
async set(key, value) { throw new Error('Not implemented'); }
async remove(key) { throw new Error('Not implemented'); }
async clear() { throw new Error('Not implemented'); }
// 频道数据
async getChannels() { throw new Error('Not implemented'); }
async setChannels(channels) { throw new Error('Not implemented'); }
async getGroups() { throw new Error('Not implemented'); }
// 订阅源
async getSubscriptions() { throw new Error('Not implemented'); }
async setSubscriptions(subs) { throw new Error('Not implemented'); }
// 线路有效性
async getValidity(url) { throw new Error('Not implemented'); }
async setValidity(url, validity) { throw new Error('Not implemented'); }
async getAllValidity() { throw new Error('Not implemented'); }
// 用户偏好
async getPreferences() { throw new Error('Not implemented'); }
async setPreferences(prefs) { throw new Error('Not implemented'); }
// 播放历史
async getHistory(limit = 50) { throw new Error('Not implemented'); }
async addHistory(item) { throw new Error('Not implemented'); }
async clearHistory() { throw new Error('Not implemented'); }
// 缓存元数据
async getCacheMeta(key) { throw new Error('Not implemented'); }
async setCacheMeta(key, meta) { throw new Error('Not implemented'); }
async isCacheValid(key, ttl) { throw new Error('Not implemented'); }
}
/**
* IndexedDB 实现 (Web/Desktop)
*/
class IndexedDBStorage extends IStorage {
constructor() {
super();
this.db = null;
}
async init() {
if (this.db) return;
this.db = await openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
// 存储对象定义
if (!db.objectStoreNames.contains('channels')) {
const channelStore = db.createObjectStore('channels', { keyPath: 'id' });
channelStore.createIndex('group', 'group');
channelStore.createIndex('name', 'name');
}
if (!db.objectStoreNames.contains('subscriptions')) {
db.createObjectStore('subscriptions', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('validity')) {
db.createObjectStore('validity', { keyPath: 'url' });
}
if (!db.objectStoreNames.contains('preferences')) {
db.createObjectStore('preferences');
}
if (!db.objectStoreNames.contains('history')) {
const historyStore = db.createObjectStore('history', {
keyPath: 'timestamp',
autoIncrement: true
});
historyStore.createIndex('channelId', 'channelId');
}
if (!db.objectStoreNames.contains('cacheMeta')) {
db.createObjectStore('cacheMeta', { keyPath: 'key' });
}
if (!db.objectStoreNames.contains('generic')) {
db.createObjectStore('generic');
}
}
});
}
// 基础操作
async get(key) {
await this.init();
return await this.db.get('generic', key);
}
async set(key, value) {
await this.init();
await this.db.put('generic', value, key);
}
async remove(key) {
await this.init();
await this.db.delete('generic', key);
}
async clear() {
await this.init();
const stores = ['channels', 'subscriptions', 'validity', 'history', 'cacheMeta', 'generic'];
for (const store of stores) {
await this.db.clear(store);
}
}
// 频道数据
async getChannels() {
await this.init();
return await this.db.getAll('channels');
}
async setChannels(channels) {
await this.init();
const tx = this.db.transaction('channels', 'readwrite');
await tx.store.clear();
for (const channel of channels) {
await tx.store.put(channel);
}
await tx.done;
// 更新缓存元数据
await this.setCacheMeta('channels', new CacheMeta({
key: 'channels',
updatedAt: Date.now(),
size: channels.length
}));
}
async getGroups() {
const channels = await this.getChannels();
const groups = new Set(channels.map(c => c.group));
return Array.from(groups);
}
// 订阅源
async getSubscriptions() {
await this.init();
return await this.db.getAll('subscriptions');
}
async setSubscriptions(subs) {
await this.init();
const tx = this.db.transaction('subscriptions', 'readwrite');
await tx.store.clear();
for (const sub of subs) {
await tx.store.put(sub);
}
await tx.done;
}
// 线路有效性
async getValidity(url) {
await this.init();
return await this.db.get('validity', url);
}
async setValidity(url, validity) {
await this.init();
await this.db.put('validity', { ...validity, url });
}
async getAllValidity() {
await this.init();
return await this.db.getAll('validity');
}
// 用户偏好
async getPreferences() {
await this.init();
const prefs = await this.db.get('preferences', 'user');
return prefs || new Preferences();
}
async setPreferences(prefs) {
await this.init();
await this.db.put('preferences', prefs, 'user');
}
// 播放历史
async getHistory(limit = 50) {
await this.init();
const all = await this.db.getAll('history');
return all.slice(-limit).reverse();
}
async addHistory(item) {
await this.init();
await this.db.add('history', item);
// 清理旧记录(保留最近 500 条)
const count = await this.db.count('history');
if (count > 500) {
const toDelete = count - 500;
const keys = await this.db.getAllKeys('history', null, toDelete);
for (const key of keys) {
await this.db.delete('history', key);
}
}
}
async clearHistory() {
await this.init();
await this.db.clear('history');
}
// 缓存元数据
async getCacheMeta(key) {
await this.init();
return await this.db.get('cacheMeta', key);
}
async setCacheMeta(key, meta) {
await this.init();
await this.db.put('cacheMeta', { ...meta, key });
}
async isCacheValid(key, ttl) {
const meta = await this.getCacheMeta(key);
if (!meta || !meta.updatedAt) return false;
return Date.now() - meta.updatedAt < ttl;
}
}
/**
* Native 存储实现 (Android/TV)
* 通过 AndroidAsset 接口调用原生 SQLite/SharedPreferences
*/
class NativeStorage extends IStorage {
constructor() {
super();
this.memoryCache = new Map(); // 内存缓存
}
// 检查 Android 接口是否可用
isAvailable() {
return typeof window.AndroidAsset !== 'undefined';
}
// 调用原生接口
async callNative(method, ...args) {
if (!this.isAvailable()) {
throw new Error('AndroidAsset not available');
}
return window.AndroidAsset[method](...args);
}
// 基础操作 (使用 localStorage 作为后备)
async get(key) {
try {
const value = await this.callNative('getItem', key);
return value ? JSON.parse(value) : null;
} catch {
return this.memoryCache.get(key) || null;
}
}
async set(key, value) {
this.memoryCache.set(key, value);
try {
await this.callNative('setItem', key, JSON.stringify(value));
} catch {
// 忽略原生存储失败
}
}
async remove(key) {
this.memoryCache.delete(key);
try {
await this.callNative('removeItem', key);
} catch {}
}
async clear() {
this.memoryCache.clear();
try {
await this.callNative('clear');
} catch {}
}
// 频道数据 (使用专用接口)
async getChannels() {
try {
const data = await this.callNative('readChannelData');
if (data && !data.startsWith('ERROR:')) {
// 解析频道数据并缓存
const channels = this.parseChannelData(data);
this.memoryCache.set('channels', channels);
return channels;
}
} catch {}
return this.memoryCache.get('channels') || [];
}
async setChannels(channels) {
this.memoryCache.set('channels', channels);
// 原生端通过文件存储,这里只更新内存缓存
await this.setCacheMeta('channels', new CacheMeta({
key: 'channels',
updatedAt: Date.now(),
size: channels.length
}));
}
parseChannelData(text) {
// 简单的 TXT 格式解析
const channels = [];
const lines = text.split('\n');
let currentGroup = '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.includes('#genre#')) {
currentGroup = trimmed.split(',')[0];
} else if (trimmed.includes(',')) {
const [name, url] = trimmed.split(',').map(s => s.trim());
if (name && url) {
const existing = channels.find(c => c.name === name && c.group === currentGroup);
if (existing) {
existing.urls.push(url);
} else {
channels.push(new Channel({
id: `${currentGroup}_${name}`,
name,
group: currentGroup,
urls: [url]
}));
}
}
}
}
return channels;
}
async getGroups() {
const channels = await this.getChannels();
const groups = new Set(channels.map(c => c.group));
return Array.from(groups);
}
// 订阅源
async getSubscriptions() {
return await this.get('subscriptions') || [];
}
async setSubscriptions(subs) {
await this.set('subscriptions', subs);
}
// 线路有效性 (使用 SharedPreferences)
async getValidity(url) {
const all = await this.getAllValidity();
return all.find(v => v.url === url);
}
async setValidity(url, validity) {
const all = await this.getAllValidity();
const index = all.findIndex(v => v.url === url);
if (index >= 0) {
all[index] = { ...validity, url };
} else {
all.push({ ...validity, url });
}
await this.set('validity', all);
}
async getAllValidity() {
return await this.get('validity') || [];
}
// 用户偏好
async getPreferences() {
return await this.get('preferences') || new Preferences();
}
async setPreferences(prefs) {
await this.set('preferences', prefs);
}
// 播放历史
async getHistory(limit = 50) {
const all = await this.get('history') || [];
return all.slice(-limit).reverse();
}
async addHistory(item) {
const all = await this.get('history') || [];
all.push(item);
// 保留最近 500 条
if (all.length > 500) {
all.splice(0, all.length - 500);
}
await this.set('history', all);
}
async clearHistory() {
await this.remove('history');
}
// 缓存元数据
async getCacheMeta(key) {
const all = await this.get('cacheMeta') || {};
return all[key];
}
async setCacheMeta(key, meta) {
const all = await this.get('cacheMeta') || {};
all[key] = { ...meta, key };
await this.set('cacheMeta', all);
}
async isCacheValid(key, ttl) {
const meta = await this.getCacheMeta(key);
if (!meta || !meta.updatedAt) return false;
return Date.now() - meta.updatedAt < ttl;
}
}
/**
* 存储工厂 - 根据平台创建对应的存储实例
*/
export function createStorage() {
const platform = import.meta.env.VITE_PLATFORM || 'web';
if (platform === 'android' || platform === 'tv') {
return new NativeStorage();
}
return new IndexedDBStorage();
}
// 导出类型和类
export {
IStorage,
IndexedDBStorage,
NativeStorage,
Subscription,
Channel,
SourceValidity,
Preferences,
PlayHistory,
CacheMeta
};
// 默认导出
export default createStorage;

72
ui/src/storage/types.js Normal file
View File

@ -0,0 +1,72 @@
/**
* 存储类型定义
*/
// 订阅源
export class Subscription {
constructor(data = {}) {
this.id = data.id || crypto.randomUUID();
this.name = data.name || '';
this.url = data.url || '';
this.type = data.type || 'm3u'; // 'm3u' | 'txt'
this.enabled = data.enabled !== false;
this.lastUpdated = data.lastUpdated || 0;
this.etag = data.etag || '';
}
}
// 频道
export class Channel {
constructor(data = {}) {
this.id = data.id || '';
this.name = data.name || '';
this.group = data.group || '';
this.urls = data.urls || []; // string[]
this.logo = data.logo || '';
this.epgId = data.epgId || '';
}
}
// 线路有效性
export class SourceValidity {
constructor(data = {}) {
this.url = data.url || '';
this.status = data.status || 'unknown'; // 'online' | 'offline' | 'unknown'
this.checkedAt = data.checkedAt || 0;
this.latency = data.latency || 0;
this.failCount = data.failCount || 0;
}
}
// 用户偏好
export class Preferences {
constructor(data = {}) {
this.defaultGroup = data.defaultGroup || '';
this.autoPlay = data.autoPlay !== false;
this.preferredQuality = data.preferredQuality || 'auto'; // 'auto' | 'hd' | 'sd'
this.volume = data.volume ?? 1.0;
this.listCacheTTL = data.listCacheTTL || 24 * 60 * 60 * 1000; // 1天
this.validityCacheTTL = data.validityCacheTTL || 12 * 60 * 60 * 1000; // 12小时
}
}
// 播放历史
export class PlayHistory {
constructor(data = {}) {
this.channelId = data.channelId || '';
this.channelName = data.channelName || '';
this.sourceUrl = data.sourceUrl || '';
this.timestamp = data.timestamp || Date.now();
this.duration = data.duration || 0;
}
}
// 缓存元数据
export class CacheMeta {
constructor(data = {}) {
this.key = data.key || '';
this.updatedAt = data.updatedAt || 0;
this.size = data.size || 0;
this.version = data.version || 1;
}
}