feat: ui改版

This commit is contained in:
liyanyan 2026-02-11 00:20:00 +08:00
parent c0428c5d3d
commit d0a27d9871
27 changed files with 15064 additions and 2415 deletions

906
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,284 +1,39 @@
<template> <template>
<div class="iptv-app"> <div class="iptv-app">
<!-- 全屏视频播放器 --> <!-- 全屏视频播放器 -->
<VideoPlayer ref="playerRef" :url="currentUrl" @error="handlePlayError" /> <!-- <VideoPlayer /> -->
<!-- 左侧面板四栏 --> <!-- 左侧面板四栏 -->
<LeftPanel <!-- <LeftPanel /> -->
:current-channel="currentChannel"
:validity-map="validityMap"
@play="handlePlay"
/>
<!-- 底部信息栏 -->
<BottomPanel
:channel="currentChannel"
:current-source-index="currentSourceIndex"
:current-program="currentProgramTitle"
:progress="programProgress"
:current-time="currentTime"
:total-time="totalTime"
:is-favorite="isFavorite(currentChannel?.id)"
@favorite="toggleFavorite(currentChannel?.id)"
@switch-source="showSourceSelector = true"
@settings="showSettings = true"
/>
<!-- 线路选择弹窗 -->
<SourceModal
v-if="showSourceSelector"
:channel="currentChannel"
:current-url="currentUrl"
:validity-map="validityMap"
@switch="switchSource"
@close="showSourceSelector = false"
/>
<!-- 设置弹窗 -->
<SettingsModal
v-if="showSettings"
@close="showSettings = false"
@reload="reloadChannels"
/>
<!-- 遥控数字输入显示 --> <!-- 遥控数字输入显示 -->
<InputPanel @complete="handleRemoteInputComplete" /> <!-- <InputPanel /> -->
<!-- 底部信息栏 -->
<!-- <BottomPanel /> -->
<!-- 线路选择弹窗 -->
<!-- <SourceModal v-if="showSourceSelector" /> -->
<!-- 设置弹窗 -->
<!-- <SettingsModal v-if="showSettings" /> -->
<!-- 调试面板 --> <!-- 调试面板 -->
<DebugPanel /> <!-- <DebugPanel /> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch } from "vue"; // import VideoPlayer from "./components/VideoPlayer.vue";
import { useStorage } from "./composables/useStorage.js"; // import LeftPanel from "./components/Layout/LeftPanel.vue";
import { useUI } from "./composables/useUI.js"; // import InputPanel from "./components/Layout/InputPanel.vue";
import { useFavorites } from "./composables/useFavorites.js"; // import BottomPanel from "./components/Layout/BottomPanel.vue";
import { useHistory } from "./composables/useHistory.js"; // import SourceModal from "./components/Modals/SourceModal.vue";
import { useChannels } from "./composables/useChannels.js"; // import SettingsModal from "./components/Modals/SettingsModal.vue";
import DebugPanel from "./components/Layout/DebugPanel.vue"; // import DebugPanel from "./components/Layout/DebugPanel.vue";
import VideoPlayer from "./components/VideoPlayer.vue"; import { useStorage } from "./composables/useStorage";
import LeftPanel from "./components/Layout/LeftPanel.vue";
import InputPanel from "./components/Layout/InputPanel.vue";
import BottomPanel from "./components/Layout/BottomPanel.vue";
import SourceModal from "./components/Modals/SourceModal.vue";
import SettingsModal from "./components/Modals/SettingsModal.vue";
import { useKeyEvent } from "./composables/useEvent.js";
// Storage
const storage = useStorage(); const storage = useStorage();
// UI
const { showLeftPanel, showBottomPanel, hideLeftPanel } = useUI();
// hooks使 useStorage
const { toggleFavorite, isFavorite } = useFavorites();
const { addToHistory } = useHistory();
const { channels, loadChannels } = useChannels();
const showSourceSelector = ref(false);
const showSettings = ref(false);
//
const playerRef = ref(null);
const currentUrl = ref("");
const currentSourceIndex = ref(0);
//
const currentChannel = ref(null);
//
async function handlePlay(channel) {
if (!channel || !channel.urls || channel.urls.length === 0) return;
currentChannel.value = channel;
// 线
// const sortedUrls = await sortUrlsBySpeed(channel.urls);
// currentUrl.value = sortedUrls[0] || channel.urls[0];
currentUrl.value = channel.urls[0];
currentSourceIndex.value = 0;
showBottomPanel();
hideLeftPanel();
//
await addToHistory(channel);
}
//
async function sortUrlsBySpeed(urls) {
if (!urls || urls.length === 0) return [];
const results = await Promise.all(
urls.map(async (url) => {
//
const cached = validityMap.value.get(url);
if (cached && Date.now() - cached.checkedAt < 60 * 60 * 1000) {
// 1
return { url, ...cached };
}
// HTTP HEAD
const startTime = Date.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
method: "HEAD",
signal: controller.signal,
mode: "no-cors", //
});
clearTimeout(timeout);
const latency = Date.now() - startTime;
const validity = {
status: "online",
checkedAt: Date.now(),
latency,
failCount: 0,
};
validityMap.value.set(url, validity);
await storage.setValidity(url, validity);
return { url, ...validity };
} catch (error) {
//
const cached = validityMap.value.get(url);
const failCount = (cached?.failCount || 0) + 1;
const validity = {
status: failCount >= 3 ? "offline" : "unknown",
checkedAt: Date.now(),
latency: Infinity,
failCount,
};
validityMap.value.set(url, validity);
await storage.setValidity(url, validity);
return { url, ...validity };
}
}),
);
// 线线
results.sort((a, b) => {
if (a.status === "offline" && b.status !== "offline") return 1;
if (a.status !== "offline" && b.status === "offline") return -1;
return a.latency - b.latency;
});
return results.map((r) => r.url);
}
// 线
function switchSource(source, index) {
currentUrl.value = source.url;
currentSourceIndex.value = index;
playerRef.value?.play(source.url);
showSourceSelector.value = false;
}
//
async function handlePlayError() {
if (!currentChannel.value) return;
const urls = currentChannel.value.urls;
if (urls.length <= 1) return; // 线
// 线
const nextIndex = (currentSourceIndex.value + 1) % urls.length;
if (nextIndex === 0) {
// 线
console.error("所有线路都失败");
return;
}
currentSourceIndex.value = nextIndex;
currentUrl.value = urls[nextIndex];
// 线
const url = urls[currentSourceIndex.value];
const cached = validityMap.value.get(url) || {};
validityMap.value.set(url, {
...cached,
status: "offline",
failCount: (cached.failCount || 0) + 1,
});
}
//
async function reloadChannels() {
await loadChannels(true);
}
// TODO: EPG
const currentProgramTitle = ref("精彩节目");
const programProgress = ref(0);
const currentTime = ref("--:--");
const totalTime = ref("--:--");
//
function updateProgramInfo() {
const now = new Date();
currentTime.value = now.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
// TODO: EPG
}
// 线
const validityMap = ref(new Map());
async function loadValidityCache() {
const all = await storage.getAllValidity();
for (const v of all) {
validityMap.value.set(v.url, v);
}
}
//
onMounted(async () => {
await loadValidityCache();
// useChannels
//
setInterval(updateProgramInfo, 60000);
updateProgramInfo();
});
// 线
watch(currentChannel, async (channel) => {
if (!channel || !channel.urls) return;
//
const needCheck = channel.urls.some((url) => {
const cached = validityMap.value.get(url);
return !cached || Date.now() - cached.checkedAt > 60 * 60 * 1000;
});
if (needCheck) {
//
sortUrlsBySpeed(channel.urls);
}
});
//
function handleRemoteInputComplete(value) {
console.log("遥控输入完成:", value);
//
const channelIndex = parseInt(value, 10) - 1;
if (channelIndex >= 0 && channelIndex < channels.value.length) {
handlePlay(channels.value[channelIndex]);
}
}
useKeyEvent("Enter", showLeftPanel);
useKeyEvent("Escape", showBottomPanel);
</script> </script>
<style scoped> <style scoped>

View File

@ -1,6 +1,6 @@
<template> <template>
<Transition name="slide-up"> <Transition name="slide-up">
<div <div
v-show="bottomPanelVisible" v-show="bottomPanelVisible"
class="bottom-panel" class="bottom-panel"
@mouseenter="showBottomPanel" @mouseenter="showBottomPanel"
@ -13,12 +13,18 @@
</div> </div>
<div class="channel-info"> <div class="channel-info">
<div class="channel-name-row"> <div class="channel-name-row">
<span class="name">{{ channel?.name || '未选择频道' }}</span> <span class="name">{{ channel?.name || "未选择频道" }}</span>
<span class="source-tag">线路 {{ currentSourceIndex + 1 }}/{{ channel?.urls?.length || 0 }}</span> <span class="source-tag"
>线路 {{ currentSourceIndex + 1 }}/{{
channel?.urls?.length || 0
}}</span
>
</div> </div>
<div class="program-info"> <div class="program-info">
<span class="live-dot"></span> <span class="live-dot"></span>
<span class="program-title">{{ currentProgram || '精彩节目' }}</span> <span class="program-title">{{
currentProgram || "精彩节目"
}}</span>
</div> </div>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div> <div class="progress-fill" :style="{ width: progress + '%' }"></div>
@ -29,17 +35,17 @@
<!-- 右侧操作按钮 --> <!-- 右侧操作按钮 -->
<div class="panel-right"> <div class="panel-right">
<button <button
class="action-btn" class="action-btn"
:class="{ active: isFavorite }" :class="{ active: isFavorite }"
@click="handleFavorite" @click="handleFavorite"
@mouseenter="showBottomPanel" @mouseenter="showBottomPanel"
> >
<span class="icon"></span> <span class="icon"></span>
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span> <span>{{ isFavorite ? "已收藏" : "收藏" }}</span>
</button> </button>
<button <button
class="action-btn" class="action-btn"
@click="handleSwitchSource" @click="handleSwitchSource"
@mouseenter="showBottomPanel" @mouseenter="showBottomPanel"
@ -47,77 +53,15 @@
<span class="icon"></span> <span class="icon"></span>
<span>切换线路</span> <span>切换线路</span>
</button> </button>
<button
class="action-btn"
@click="handleSettings"
@mouseenter="showBottomPanel"
>
<span class="icon"></span>
<span>设置</span>
</button>
</div> </div>
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup> <script setup>
const props = defineProps({ import { useKeyEvent } from "../../composables/useEvent.js";
channel: {
type: Object,
default: null
},
currentSourceIndex: {
type: Number,
default: 0
},
currentProgram: {
type: String,
default: ''
},
progress: {
type: Number,
default: 0
},
currentTime: {
type: String,
default: '--:--'
},
totalTime: {
type: String,
default: '--:--'
},
isFavorite: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['favorite', 'switch-source', 'settings']); useKeyEvent("Escape", showBottomPanel);
import { useUI } from "../../composables/useUI.js";
const { bottomPanelVisible, showBottomPanel } = useUI();
// LOGO
function getChannelLogo(name) {
return name ? name.slice(0, 2) : '--';
}
//
function handleFavorite() {
emit('favorite');
showBottomPanel();
}
// 线
function handleSwitchSource() {
emit('switch-source');
}
//
function handleSettings() {
emit('settings');
}
</script> </script>
<style scoped> <style scoped>

View File

@ -9,9 +9,10 @@
<script setup> <script setup>
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useEvent } from "../../composables/useEvent.js"; import { useEvent } from "../../composables/useEvent.js";
import { usePlayer } from "../../composables/usePlayer.js";
import { debounce } from "../../utils/common.js"; import { debounce } from "../../utils/common.js";
const emit = defineEmits(["complete"]); const { showLeftPanel } = usePlayer();
// //
const visible = ref(false); const visible = ref(false);

View File

@ -78,7 +78,7 @@
</div> </div>
<!-- 第三栏日期列表 --> <!-- 第三栏日期列表 -->
<div class="column column-3" :class="{ active: activeColumn === 2 }"> <!-- <div class="column column-3" :class="{ active: activeColumn === 2 }">
<div class="date-list" :class="{ 'is-active': activeColumn === 2 }"> <div class="date-list" :class="{ 'is-active': activeColumn === 2 }">
<div <div
v-for="date in dates" v-for="date in dates"
@ -91,10 +91,10 @@
<div class="date-label">{{ date.label }}</div> <div class="date-label">{{ date.label }}</div>
</div> </div>
</div> </div>
</div> </div> -->
<!-- 第四栏节目单列表 --> <!-- 第四栏节目单列表 -->
<div class="column column-4" :class="{ active: activeColumn === 3 }"> <!-- <div class="column column-4" :class="{ active: activeColumn === 3 }">
<div class="program-list" :class="{ 'is-active': activeColumn === 3 }"> <div class="program-list" :class="{ 'is-active': activeColumn === 3 }">
<div <div
v-for="program in programs" v-for="program in programs"
@ -113,102 +113,37 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div> -->
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup> <script setup>
import { computed, watch } from "vue"; import { computed } from "vue";
import { useUI } from "../../composables/useUI.js"; import { useUI } from "../../composables/useUI.js";
import { useKeyEvent } from "../../composables/useEvent.js"; import { useKeyEvent } from "../../composables/useEvent.js";
import { useGroups } from "../../composables/useGroups.js"; import { useGroups } from "../../composables/useGroups.js";
import { useChannelFilter } from "../../composables/useChannelFilter.js"; import { useChannelFilter } from "../../composables/useChannelFilter.js";
import { useDates } from "../../composables/useDates.js";
import { usePrograms } from "../../composables/usePrograms.js";
import { useFavorites } from "../../composables/useFavorites.js";
import { useChannels } from "../../composables/useChannels.js";
const props = defineProps({
currentChannel: {
type: Object,
default: null,
},
validityMap: {
type: Map,
default: () => new Map(),
},
});
const emit = defineEmits(["play"]); const emit = defineEmits(["play"]);
const { isTV, leftPanelVisible, hideLeftPanel, currentActiveColumn } = useUI(); const { isTV, leftPanelVisible, hideLeftPanel, currentActiveColumn } = useUI();
const activeColumn = computed(() => currentActiveColumn.value); const activeColumn = computed(() => currentActiveColumn.value);
// hooks
const { favorites } = useFavorites();
const { channels, groups } = useChannels();
// ===== ===== // ===== =====
const { const { selectedGroup, pinnedGroups, normalGroups, selectGroup } = useGroups();
selectedGroup,
pinnedGroups,
normalGroups,
selectGroup,
setGroups,
initSelectedGroup,
} = useGroups();
// ===== ===== // ===== =====
const { const {
selectedChannel, selectedChannel,
filteredChannels, filteredChannels,
selectChannel, selectChannel,
setChannels,
setSelectedGroup: setChannelFilterGroup,
setFavorites,
setValidityMap,
getFirstChannel, getFirstChannel,
getChannelLogo, getChannelLogo,
isFavorite, isFavorite,
getValidCount, getValidCount,
} = useChannelFilter(); } = useChannelFilter();
// ===== =====
const { selectedDate, dates, selectDate, initToday } = useDates();
// ===== =====
const { selectedProgram, programs, selectProgram, loadProgramsByDate } =
usePrograms();
// hooks
watch(groups, (g) => setGroups(g), { immediate: true });
watch(channels, (c) => setChannels(c), { immediate: true });
//
watch(favorites, (favs) => setFavorites(favs), { immediate: true });
watch(
() => props.validityMap,
(map) => setValidityMap(map),
{ immediate: true },
);
//
watch(selectedGroup, (group) => {
setChannelFilterGroup(group);
});
//
watch(leftPanelVisible, (val) => {
if (val && props.currentChannel) {
initSelectedGroup(props.currentChannel, groups.value[0]);
selectChannel(props.currentChannel);
initToday();
}
});
// //
function onGroupSelect(groupId) { function onGroupSelect(groupId) {
selectGroup(groupId); selectGroup(groupId);
@ -226,19 +161,7 @@ function onChannelSelect(channel) {
hideLeftPanel(); hideLeftPanel();
} }
// useKeyEvent("Enter", showLeftPanel);
function onDateSelect(dateValue) {
selectDate(dateValue);
loadProgramsByDate(dateValue);
}
//
function onProgramSelect(program) {
selectProgram(program);
// TODO:
hideLeftPanel();
}
useKeyEvent("Escape", hideLeftPanel); useKeyEvent("Escape", hideLeftPanel);
</script> </script>

View File

@ -0,0 +1,24 @@
// 默认设置
const defaultSettings = {
bottomPanelCloseDelay: 3000, // 底部面板自动关闭延迟(毫秒)
leftPanelCloseDelay: 500, // 左侧面板自动关闭延迟(毫秒)
inputPanelCloseDelay: 2000, // 输入面板自动关闭延迟(毫秒)
};
const settings = ref({ ...defaultSettings });
export const useApp = () => {
const { getSettings } = useStorage();
const initSetting = async () => {
const saved = await getSettings();
if (saved) {
settings.value = { ...defaultSettings, ...saved };
}
};
return {
settings,
initSetting,
};
};

View File

@ -1,87 +0,0 @@
/**
* 频道检测结果缓存
* 避免每次打开都重复检测
*/
const CACHE_KEY = 'iptv_channel_cache'
const CACHE_VERSION = '1.0'
const DEFAULT_EXPIRE = 24 * 60 * 60 * 1000 // 默认缓存24小时
// 获取缓存
export function getCachedChannels() {
try {
const saved = localStorage.getItem(CACHE_KEY)
if (!saved) return null
const { version, timestamp, data } = JSON.parse(saved)
// 版本检查
if (version !== CACHE_VERSION) {
console.log('[Cache] 版本过期,清除缓存')
clearCache()
return null
}
// 过期检查(使用用户配置的过期时间)
const expireTime = parseInt(localStorage.getItem('iptv_cache_expire')) || DEFAULT_EXPIRE
const age = Date.now() - timestamp
if (age > expireTime) {
console.log('[Cache] 缓存已过期,年龄:', Math.round(age / 1000 / 60), '分钟')
return null
}
console.log('[Cache] 命中缓存,年龄:', Math.round(age / 1000 / 60), '分钟,剩余:', Math.round((expireTime - age) / 1000 / 60), '分钟')
return data
} catch (e) {
console.error('[Cache] 读取失败:', e)
return null
}
}
// 设置缓存
export function setCachedChannels(channels) {
try {
const cache = {
version: CACHE_VERSION,
timestamp: Date.now(),
data: channels
}
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
console.log('[Cache] 已保存', channels.length, '个频道')
} catch (e) {
console.error('[Cache] 保存失败:', e)
}
}
// 清除缓存
export function clearCache() {
localStorage.removeItem(CACHE_KEY)
console.log('[Cache] 已清除')
}
// 检查是否有有效缓存
export function hasValidCache() {
return getCachedChannels() !== null
}
// 获取缓存信息
export function getCacheInfo() {
try {
const saved = localStorage.getItem(CACHE_KEY)
if (!saved) return null
const { timestamp } = JSON.parse(saved)
const expireTime = parseInt(localStorage.getItem('iptv_cache_expire')) || DEFAULT_EXPIRE
const age = Date.now() - timestamp
const remaining = expireTime - age
return {
age: Math.max(0, Math.round(age / 1000 / 60)), // 已缓存分钟数
remaining: Math.max(0, Math.round(remaining / 1000 / 60)), // 剩余分钟数
isValid: remaining > 0
}
} catch {
return null
}
}

View File

@ -1,94 +0,0 @@
import { ref, computed } from "vue";
export function useChannelFilter() {
const selectedChannel = ref(null);
const allChannels = ref([]);
const selectedGroup = ref("");
const favorites = ref(new Set());
const validityMap = ref(new Map());
// 过滤后的频道
const filteredChannels = computed(() => {
if (!selectedGroup.value) return allChannels.value;
// 置顶分组特殊处理
if (selectedGroup.value === "recent") {
// TODO: 返回最近播放
return [];
}
if (selectedGroup.value === "favorite") {
return allChannels.value.filter((c) => favorites.value.has(c.id));
}
return allChannels.value.filter((c) => c.group === selectedGroup.value);
});
// 获取频道 LOGO取前两个字符
function getChannelLogo(name) {
return name?.slice(0, 2) || "--";
}
// 检查是否收藏
function isFavorite(channelId) {
return favorites.value.has(channelId);
}
// 获取有效线路数
function getValidCount(channel) {
if (!channel?.urls) return 0;
return channel.urls.filter((url) => {
const validity = validityMap.value.get(url);
return validity?.status === "online";
}).length;
}
// 选择频道
function selectChannel(channel) {
selectedChannel.value = channel;
return channel;
}
// 设置数据源
function setChannels(channels) {
allChannels.value = channels;
}
function setSelectedGroup(group) {
selectedGroup.value = group;
}
function setFavorites(favSet) {
favorites.value = favSet;
}
function setValidityMap(map) {
validityMap.value = map;
}
// 根据当前频道初始化
function initSelectedChannel(channel) {
if (channel) {
selectedChannel.value = channel;
}
}
// 获取第一个频道(用于分组切换时自动选中)
function getFirstChannel() {
return filteredChannels.value[0] || null;
}
return {
selectedChannel,
filteredChannels,
selectChannel,
setChannels,
setSelectedGroup,
setFavorites,
setValidityMap,
initSelectedChannel,
getFirstChannel,
getChannelLogo,
isFavorite,
getValidCount,
};
}

View File

@ -1,151 +0,0 @@
import { ref, computed, onMounted } from "vue";
import { useStorage } from "./useStorage.js";
// 全局状态
const channels = ref([]);
const loading = ref(false);
let initialized = false;
export function useChannels() {
const storage = useStorage();
// 分组列表(基于频道计算)
const groups = computed(() => {
return [...new Set(channels.value.map((c) => c.group))];
});
// 加载频道数据
const loadChannels = async (force = false) => {
if (loading.value) return;
loading.value = true;
try {
// 检查缓存
if (!force) {
const isValid = await storage.isListCacheValid();
if (isValid) {
const cached = await storage.getChannels();
if (cached && cached.length > 0) {
channels.value = cached;
loading.value = false;
// 后台刷新
refreshChannelsInBackground();
return;
}
}
}
// 从网络加载
const text = await fetchChannelData();
if (text && !text.startsWith("ERROR")) {
const parsed = parseChannelData(text);
channels.value = parsed;
await storage.setChannels(parsed);
}
} finally {
loading.value = false;
}
};
// 后台刷新频道
const refreshChannelsInBackground = async () => {
try {
const text = await fetchChannelData();
if (text && !text.startsWith("ERROR")) {
const parsed = parseChannelData(text);
if (parsed.length > 0) {
channels.value = parsed;
await storage.setChannels(parsed);
}
}
} catch (e) {
console.error("后台刷新失败:", e);
}
};
// 获取频道数据(网络)
const fetchChannelData = async () => {
const platform = import.meta.env.VITE_PLATFORM || "web";
// Android/TV 使用原生接口
if ((platform === "android" || platform === "tv") && window.AndroidAsset) {
try {
return window.AndroidAsset.readChannelData();
} catch (e) {
console.error("读取本地数据失败:", e);
}
}
// Web/Desktop 从网络加载
try {
const response = await fetch(
"https://cdn.jsdelivr.net/gh/Guovin/iptv-api@gd/output/result.txt"
);
if (response.ok) {
return await response.text();
}
} catch (e) {
console.error("网络加载失败:", e);
}
return null;
};
// 解析频道数据
const parseChannelData = (text) => {
const result = [];
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 = result.find(
(c) => c.name === name && c.group === currentGroup
);
if (existing) {
existing.urls.push(url);
} else {
result.push({
id: `${currentGroup}_${name}`,
name,
group: currentGroup,
urls: [url],
logo: "",
epgId: "",
});
}
}
}
}
return result;
};
// 根据索引获取频道
const getChannelByIndex = (index) => {
return channels.value[index] || null;
};
// 初始化加载
onMounted(() => {
if (!initialized) {
loadChannels();
initialized = true;
}
});
return {
channels,
groups,
loading,
loadChannels,
getChannelByIndex,
};
}

View File

@ -1,47 +0,0 @@
import { ref, computed } from "vue";
// 日期标签
const DAY_LABELS = ["今天", "明天", "后天"];
export function useDates() {
const selectedDate = ref("");
// 生成日期列表(今天、明天、后天...
const dates = computed(() => {
const list = [];
const today = new Date();
for (let i = 0; i < 7; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
const value = date.toISOString().split("T")[0];
const day = `${date.getMonth() + 1}/${date.getDate()}`;
const label =
i < 3
? DAY_LABELS[i]
: `${date.getMonth() + 1}${date.getDate()}`;
list.push({ value, day, label });
}
return list;
});
// 选择日期
function selectDate(dateValue) {
selectedDate.value = dateValue;
}
// 初始化为今天
function initToday() {
selectedDate.value = new Date().toISOString().split("T")[0];
}
return {
selectedDate,
dates,
selectDate,
initToday,
};
}

View File

@ -1,76 +0,0 @@
import { ref, onMounted } from "vue";
import { useStorage } from "./useStorage.js";
const STORAGE_KEY = "favorites_data";
// 全局状态
const favorites = ref(new Set());
let initialized = false;
let loadPromise = null;
export function useFavorites() {
const storage = useStorage();
// 加载收藏(从 useStorage
const loadFavorites = async () => {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const saved = await storage.get(STORAGE_KEY);
if (saved) {
favorites.value = new Set(saved);
}
} catch (e) {
console.error("加载收藏失败:", e);
}
initialized = true;
})();
return loadPromise;
};
// 保存收藏(到 useStorage
const saveFavorites = async () => {
try {
await storage.set(STORAGE_KEY, [...favorites.value]);
} catch (e) {
console.error("保存收藏失败:", e);
}
};
// 切换收藏状态
const toggleFavorite = async (channelId) => {
if (!channelId) return;
if (favorites.value.has(channelId)) {
favorites.value.delete(channelId);
} else {
favorites.value.add(channelId);
}
await saveFavorites();
};
// 检查是否已收藏
const isFavorite = (channelId) => {
return favorites.value.has(channelId);
};
// 获取所有收藏
const getFavorites = () => {
return [...favorites.value];
};
// 初始化加载
onMounted(() => {
if (!initialized) {
loadFavorites();
}
});
return {
favorites,
toggleFavorite,
isFavorite,
getFavorites,
};
}

View File

@ -1,82 +0,0 @@
import { ref, computed } from "vue";
// 置顶分组配置
const PINNED_GROUPS = [
{ id: "recent", name: "最近播放", icon: "⏱" },
{ id: "favorite", name: "收藏", icon: "❤️" },
];
// 分组图标映射
const GROUP_ICONS = {
央视: "📺",
卫视: "📡",
体育: "⚽",
电影: "🎬",
少儿: "👶",
};
export function useGroups() {
const selectedGroup = ref("");
const rawGroups = ref([]);
// 计算置顶分组(带数量)
const pinnedGroups = computed(() => {
return PINNED_GROUPS.map((g) => ({
...g,
count: 0, // TODO: 实际数量从外部传入
}));
});
// 计算普通分组(带图标和数量)
const normalGroups = computed(() => {
return rawGroups.value.map((group) => ({
id: group,
name: group,
icon: getGroupIcon(group),
count: 0, // TODO: 实际数量从外部传入
}));
});
// 所有分组
const allGroups = computed(() => [...pinnedGroups.value, ...normalGroups.value]);
// 获取分组图标
function getGroupIcon(groupName) {
for (const [key, icon] of Object.entries(GROUP_ICONS)) {
if (groupName.includes(key)) return icon;
}
return "📺";
}
// 选择分组
function selectGroup(groupId) {
selectedGroup.value = groupId;
}
// 设置原始分组数据
function setGroups(groups) {
rawGroups.value = groups;
}
// 根据当前频道初始化选中分组
function initSelectedGroup(channel, defaultGroup = "") {
if (channel?.group) {
selectedGroup.value = channel.group;
} else if (defaultGroup) {
selectedGroup.value = defaultGroup;
} else if (rawGroups.value.length > 0) {
selectedGroup.value = rawGroups.value[0];
}
}
return {
selectedGroup,
pinnedGroups,
normalGroups,
allGroups,
selectGroup,
setGroups,
initSelectedGroup,
getGroupIcon,
};
}

View File

@ -1,84 +0,0 @@
import { ref, onMounted } from "vue";
import { useStorage } from "./useStorage.js";
const STORAGE_KEY = "play_history";
const MAX_HISTORY = 20;
// 全局状态
const history = ref([]);
let initialized = false;
let loadPromise = null;
export function useHistory() {
const storage = useStorage();
// 加载历史(从 useStorage
const loadHistory = async () => {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const saved = await storage.get(STORAGE_KEY);
if (saved) {
history.value = saved;
}
} catch (e) {
console.error("加载历史失败:", e);
}
initialized = true;
})();
return loadPromise;
};
// 保存历史(到 useStorage
const saveHistory = async () => {
try {
await storage.set(STORAGE_KEY, [...history.value]);
} catch (e) {
console.error("保存历史失败:", e);
}
};
// 添加到历史
const addToHistory = async (channel) => {
if (!channel) return;
// 移除重复项
history.value = history.value.filter((h) => h.id !== channel.id);
// 添加到开头
history.value.unshift({
...channel,
playedAt: Date.now(),
});
// 限制数量
if (history.value.length > MAX_HISTORY) {
history.value = history.value.slice(0, MAX_HISTORY);
}
await saveHistory();
};
// 清空历史
const clearHistory = async () => {
history.value = [];
await saveHistory();
};
// 获取历史列表
const getHistory = () => {
return [...history.value];
};
// 初始化加载
onMounted(() => {
if (!initialized) {
loadHistory();
}
});
return {
history,
addToHistory,
clearHistory,
getHistory,
};
}

View File

@ -1,43 +0,0 @@
/**
* 移动端检测
*/
import { ref, onMounted, onUnmounted } from 'vue'
export function useMobile() {
const isMobile = ref(false)
const isTouch = ref(false)
const checkMobile = () => {
// 检测移动设备
const userAgent = navigator.userAgent.toLowerCase()
const mobileKeywords = [
'android', 'iphone', 'ipad', 'ipod', 'windows phone',
'mobile', 'mobi', 'tablet'
]
isMobile.value = mobileKeywords.some(keyword =>
userAgent.includes(keyword)
) || window.innerWidth < 768
// 检测触摸设备
isTouch.value = 'ontouchstart' in window || navigator.maxTouchPoints > 0
}
const handleResize = () => {
checkMobile()
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
return {
isMobile,
isTouch
}
}

View File

@ -0,0 +1,60 @@
import { useApp } from "./useApp";
import { debounce } from "../utils/common";
const currentGroup = ref();
const currentChannel = ref();
const currentSource = ref();
const isShowLeftPanel = ref(false);
const isShowBottomPanel = ref(false);
const isShowInputPanel = ref(false);
const isShowDebugPanel = ref(false);
export const usePlayer = () => {
const { settings } = useApp();
const setCurrentGroup = (group) => {
currentGroup.value = group;
};
const setCurrentChannel = (channel) => {
currentChannel.value = channel;
};
const setCurrentSource = (source) => {
currentSource.value = source;
};
const showLeftPanel = () => {
isShowLeftPanel.value = true;
};
const closeLeftPanel = () => {
isShowLeftPanel.value = false;
};
const showBottomPanel = () => {
isShowBottomPanel.value = true;
closeBottomPanel();
};
const closeBottomPanel = debounce(() => {
isShowBottomPanel.value = false;
}, settings.value.bottomPanelCloseDelay);
return {
currentGroup,
setCurrentGroup,
currentChannel,
setCurrentChannel,
currentSource,
setCurrentSource,
isShowLeftPanel,
showLeftPanel,
closeLeftPanel,
isShowBottomPanel,
showBottomPanel,
closeBottomPanel,
};
};

View File

@ -1,42 +0,0 @@
import { ref, computed } from "vue";
// 模拟节目单数据TODO: 实际从 EPG 获取)
const MOCK_PROGRAMS = [
{ id: 1, time: "08:00", title: "朝闻天下", isCurrent: false },
{ id: 2, time: "09:00", title: "今日说法", isCurrent: false },
{ id: 3, time: "10:00", title: "电视剧:繁花", isCurrent: true },
{ id: 4, time: "12:00", title: "新闻30分", isCurrent: false },
{ id: 5, time: "13:00", title: "电视剧:西游记", isCurrent: false },
{ id: 6, time: "15:00", title: "动画剧场", isCurrent: false },
{ id: 7, time: "19:00", title: "新闻联播", isCurrent: false },
];
export function usePrograms() {
const selectedProgram = ref(null);
const programs = ref([...MOCK_PROGRAMS]);
// 选择节目
function selectProgram(program) {
selectedProgram.value = program;
return program;
}
// 加载指定日期的节目TODO: 实际从 EPG 获取)
function loadProgramsByDate(dateValue) {
// TODO: 根据日期加载节目单
console.log("加载节目单:", dateValue);
}
// 更新节目单
function setPrograms(newPrograms) {
programs.value = newPrograms;
}
return {
selectedProgram,
programs,
selectProgram,
loadProgramsByDate,
setPrograms,
};
}

View File

@ -1,85 +0,0 @@
import { ref, onMounted } from "vue";
import { useStorage } from "./useStorage.js";
const STORAGE_KEY = "user_settings";
// 默认设置
const defaultSettings = {
autoPlay: true,
defaultVolume: 0.8,
showEpg: true,
theme: "dark",
checkTimeout: 2000, // 检测超时时间(ms)
checkConcurrency: 5, // 并发数
};
// 全局状态
const settings = ref({ ...defaultSettings });
let initialized = false;
let loadPromise = null;
export function useSettings() {
const storage = useStorage();
// 加载设置(从 useStorage
const loadSettings = async () => {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const saved = await storage.get(STORAGE_KEY);
if (saved) {
settings.value = { ...defaultSettings, ...saved };
}
} catch (e) {
console.error("加载设置失败:", e);
}
initialized = true;
})();
return loadPromise;
};
// 保存设置(到 useStorage
const saveSettings = async () => {
try {
await storage.set(STORAGE_KEY, settings.value);
} catch (e) {
console.error("保存设置失败:", e);
}
};
// 更新单个设置
const updateSetting = async (key, value) => {
if (key in settings.value) {
settings.value[key] = value;
await saveSettings();
}
};
// 更新多个设置
const updateSettings = async (newSettings) => {
settings.value = { ...settings.value, ...newSettings };
await saveSettings();
};
// 重置设置为默认值
const resetSettings = async () => {
settings.value = { ...defaultSettings };
await saveSettings();
};
// 初始化加载
onMounted(() => {
if (!initialized) {
loadSettings();
}
});
return {
settings,
updateSetting,
updateSettings,
resetSettings,
};
}

View File

@ -1,19 +1,10 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted } from "vue";
import createStorage, { Subscription, Channel, SourceValidity, Preferences, PlayHistory, CacheMeta } from '../storage/index.js'; import createStorage from "../storage/index.js";
const storage = createStorage(); const storage = createStorage();
const isReady = ref(false); const isReady = ref(false);
export { Subscription, Channel, SourceValidity, Preferences, PlayHistory, CacheMeta };
export function useStorage() { export function useStorage() {
onMounted(async () => {
if (storage.init) {
await storage.init();
}
isReady.value = true;
});
// 基础操作 // 基础操作
const get = async (key) => { const get = async (key) => {
return await storage.get(key); return await storage.get(key);
@ -27,143 +18,28 @@ export function useStorage() {
return await storage.remove(key); return await storage.remove(key);
}; };
// 频道数据
const getChannels = async () => {
const channels = await storage.getChannels();
return channels.map(c => new Channel(c));
};
const setChannels = async (channels) => {
return await storage.setChannels(channels);
};
const getGroups = async () => {
return await storage.getGroups();
};
// 检查列表缓存是否有效
const isListCacheValid = async () => {
const prefs = await storage.getPreferences();
const ttl = prefs.listCacheTTL || 24 * 60 * 60 * 1000;
return await storage.isCacheValid('channels', ttl);
};
// 线路有效性
const getValidity = async (url) => {
const v = await storage.getValidity(url);
return v ? new SourceValidity(v) : null;
};
const setValidity = async (url, validity) => {
return await storage.setValidity(url, validity);
};
const getAllValidity = async () => {
const list = await storage.getAllValidity();
return list.map(v => new SourceValidity(v));
};
// 检查有效性缓存是否有效
const isValidityCacheValid = async (url) => {
const prefs = await storage.getPreferences();
const ttl = prefs.validityCacheTTL || 12 * 60 * 60 * 1000;
const validity = await storage.getValidity(url);
if (!validity || !validity.checkedAt) return false;
return Date.now() - validity.checkedAt < ttl;
};
// 用户偏好
const getPreferences = async () => {
const prefs = await storage.getPreferences();
return new Preferences(prefs);
};
const setPreferences = async (prefs) => {
return await storage.setPreferences(prefs);
};
// 播放历史
const getHistory = async (limit = 50) => {
const list = await storage.getHistory(limit);
return list.map(h => new PlayHistory(h));
};
const addHistory = async (item) => {
return await storage.addHistory(item);
};
const clearHistory = async () => {
return await storage.clearHistory();
};
// 订阅源
const getSubscriptions = async () => {
const list = await storage.getSubscriptions();
return list.map(s => new Subscription(s));
};
const setSubscriptions = async (subs) => {
return await storage.setSubscriptions(subs);
};
// 缓存元数据
const getCacheMeta = async (key) => {
const meta = await storage.getCacheMeta(key);
return meta ? new CacheMeta(meta) : null;
};
const setCacheMeta = async (key, meta) => {
return await storage.setCacheMeta(key, meta);
};
const isCacheValid = async (key, ttl) => {
return await storage.isCacheValid(key, ttl);
};
// 清空所有数据 // 清空所有数据
const clear = async () => { const clear = async () => {
return await storage.clear(); return await storage.clear();
}; };
// 频道数据
const getGroups = async () => {
return await storage.getGroups();
};
const getChannels = async () => {
const channels = await storage.getChannels();
return channels.map((c) => new Channel(c));
};
return { return {
isReady,
// 基础
get, get,
set, set,
remove, remove,
clear, clear,
// 频道 // 频道
getChannels, getChannels,
setChannels,
getGroups, getGroups,
isListCacheValid,
// 有效性
getValidity,
setValidity,
getAllValidity,
isValidityCacheValid,
// 偏好
getPreferences,
setPreferences,
// 历史
getHistory,
addHistory,
clearHistory,
// 订阅
getSubscriptions,
setSubscriptions,
// 元数据
getCacheMeta,
setCacheMeta,
isCacheValid,
}; };
} }
// 创建单例
let storageInstance = null;
export function useStorageSingleton() {
if (!storageInstance) {
storageInstance = useStorage();
}
return storageInstance;
}

View File

@ -0,0 +1,18 @@
import { useStorage } from "./useStorage";
const groups = ref([]);
const channels = ref([]);
export const useStore = () => {
const initStore = () => {
const { getGroups, getChannels } = useStorage();
groups.value = getGroups();
channels.value = getChannels();
};
return {
groups,
channels,
initStore,
};
};

View File

@ -1,109 +0,0 @@
import { ref, computed } from "vue";
import { debounce } from "../utils/common.js";
// 当前激活的栏索引用于TV导航
const activeColumnIndex = ref(0);
const leftPanelVisible = ref(false);
const bottomPanelVisible = ref(false);
// 当前选中的分组/频道/日期/节目
const selectedGroup = ref("");
const selectedChannel = ref(null);
const selectedDate = ref("");
const selectedProgram = ref(null);
export function useUI() {
const platform = import.meta.env.VITE_PLATFORM || "web";
const isTV = platform === "tv";
// 显示左侧面板
const showLeftPanel = () => {
leftPanelVisible.value = true;
bottomPanelVisible.value = false;
activeColumnIndex.value = 0;
};
// 隐藏左侧面板
const hideLeftPanel = () => {
leftPanelVisible.value = false;
activeColumnIndex.value = 0;
};
// 切换左侧面板
const toggleLeftPanel = () => {
if (leftPanelVisible.value) {
hideLeftPanel();
} else {
showLeftPanel();
}
};
// 显示底部栏(启动防抖隐藏)
const showBottomPanel = () => {
bottomPanelVisible.value = true;
hideBottomPanel();
};
// 隐藏底部栏
const hideBottomPanel = debounce(() => {
bottomPanelVisible.value = false;
}, 3000);
// 设置选中项
const setSelectedGroup = (group) => {
selectedGroup.value = group;
};
const setSelectedChannel = (channel) => {
selectedChannel.value = channel;
};
const setSelectedDate = (date) => {
selectedDate.value = date;
};
const setSelectedProgram = (program) => {
selectedProgram.value = program;
};
// TV 导航:切换栏
const moveColumn = (direction) => {
if (!leftPanelVisible.value) return;
const maxIndex = 3; // 0-3 四栏
if (direction === "right") {
activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex);
} else if (direction === "left") {
activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0);
}
};
// TV 导航:获取当前激活的栏索引
const currentActiveColumn = computed(() => activeColumnIndex.value);
return {
// 状态
leftPanelVisible,
bottomPanelVisible,
selectedGroup,
selectedChannel,
selectedDate,
selectedProgram,
// 计算属性
isTV,
currentActiveColumn,
// 方法
showLeftPanel,
hideLeftPanel,
toggleLeftPanel,
showBottomPanel,
hideBottomPanel,
setSelectedGroup,
setSelectedChannel,
setSelectedDate,
setSelectedProgram,
moveColumn,
};
}

View File

@ -0,0 +1,201 @@
import { IStorage } from "./implement";
export class AndroidStorage 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;
}
}

View File

@ -0,0 +1,23 @@
export 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 getGroups() {
throw new Error("Not implemented");
}
async getChannels() {
throw new Error("Not implemented");
}
}

View File

@ -0,0 +1,65 @@
import { openDB } from "idb";
import { IStorage } from "./implement";
const DB_NAME = "IPTVStorage";
const DB_VERSION = 1;
export 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("groups")) {
db.createObjectStore("groups", {
keyPath: "id",
});
}
// 频道列表
if (!db.objectStoreNames.contains("channels")) {
db.createObjectStore("channels", {
keyPath: "id",
});
}
},
});
}
// 基础操作
async get(key) {
await this.init();
return await this.db.get("channels", key);
}
async set(key, value) {
await this.init();
await this.db.put("channels", value, key);
}
async remove(key) {
await this.init();
await this.db.delete("channels", key);
}
async clear() {
await this.init();
const stores = ["groups", "channels"];
for (const store of stores) {
await this.db.clear(store);
}
}
// 频道数据
async getChannels() {}
async setChannels(channels) {}
async getGroups() {}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
import { IStorage } from "./implement";
import { allData } from "./localStorage.data";
const groups = allData.reduce(
(res, item) => {
if (!res.obj[item.group]) {
res.obj[item.group] = { group: item.group, id: item.group, count: 0 };
res.list.push(res.obj[item.group]);
} else {
res.obj[item.group].count += 1;
}
return res;
},
{ list: [], obj: {} },
);
const channels = allData.reduce(
(res, item) => {
if (!res.obj[item.id]) {
res.obj[item.id] = {
id: item.id,
name: item.id,
groupId: item.group,
total: 0,
active: 0,
urls: [],
};
res.list.push(res.obj[item.id]);
}
if (!res.obj[item.id].urls.includes(item.url)) {
res.obj[item.id].urls.push(item.url);
res.obj[item.id].total += 1;
}
return res;
},
{ list: [], obj: {} },
);
export class LocalStorage extends IStorage {
getGroups() {
return groups.list;
}
getChannels() {
return channels.list;
}
}

View File

@ -1,467 +1,29 @@
import { openDB } from 'idb'; import { IndexedDBStorage } from "./adapters/indexDBStorage";
import { import { AndroidStorage } from "./adapters/androidStorage";
Subscription, Channel, SourceValidity, import { LocalStorage } from "./adapters/localStorage";
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() { export function createStorage() {
const platform = import.meta.env.VITE_PLATFORM || 'web'; let storage;
const platform = import.meta.env.VITE_PLATFORM || "web";
if (platform === 'android' || platform === 'tv') { const mode = import.meta.env.MODE || "development";
return new NativeStorage();
}
return new IndexedDBStorage();
}
// 导出类型和类 if (mode === "development") {
export { storage = new LocalStorage();
IStorage, } else {
IndexedDBStorage, if ([].includes(platform)) {
NativeStorage, storage = new IndexedDBStorage();
Subscription, } else if (["android", "tv"].includes(platform)) {
Channel, storage = new AndroidStorage();
SourceValidity, } else {
Preferences, storage = new IndexedDBStorage();
PlayHistory, }
CacheMeta }
};
return storage;
}
// 默认导出 // 默认导出
export default createStorage; export default createStorage;

View File

@ -1,72 +0,0 @@
/**
* 存储类型定义
*/
// 订阅源
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;
}
}