refactor: 重构 App.vue 使用新架构
- 使用 useChannels 替代内联频道逻辑 - 使用 useFavorites/useHistory 替代内联收藏/历史 - 移除 channels/groups 计算属性 - 简化 props 传递到子组件 - 删除无用方法 (fetchChannelData, parseChannelData 等)
This commit is contained in:
parent
f7a8e3524c
commit
c0428c5d3d
349
ui/src/App.vue
349
ui/src/App.vue
@ -1,34 +1,17 @@
|
||||
<template>
|
||||
<div class="iptv-app">
|
||||
<!-- 调试面板 -->
|
||||
<DebugPanel v-if="showDebug" @close="showDebug = false" />
|
||||
<button v-else class="debug-toggle" @click="showDebug = true">🐛</button>
|
||||
|
||||
<!-- 全屏视频播放器 -->
|
||||
<VideoPlayer
|
||||
ref="playerRef"
|
||||
:url="currentUrl"
|
||||
:title="currentChannel?.name"
|
||||
class="video-layer"
|
||||
@error="handlePlayError"
|
||||
/>
|
||||
<VideoPlayer ref="playerRef" :url="currentUrl" @error="handlePlayError" />
|
||||
|
||||
<!-- 左侧面板(四栏) -->
|
||||
<LeftPanel
|
||||
:visible="leftPanelVisible"
|
||||
:channels="channels"
|
||||
:groups="groups"
|
||||
:current-channel="currentChannel"
|
||||
:favorites="favorites"
|
||||
:validity-map="validityMap"
|
||||
@close="hideLeftPanel"
|
||||
@play="handlePlay"
|
||||
@lookback="handleLookback"
|
||||
/>
|
||||
|
||||
<!-- 底部信息栏 -->
|
||||
<BottomPanel
|
||||
:visible="bottomPanelVisible"
|
||||
:channel="currentChannel"
|
||||
:current-source-index="currentSourceIndex"
|
||||
:current-program="currentProgramTitle"
|
||||
@ -39,7 +22,6 @@
|
||||
@favorite="toggleFavorite(currentChannel?.id)"
|
||||
@switch-source="showSourceSelector = true"
|
||||
@settings="showSettings = true"
|
||||
@interaction="onBottomInteraction"
|
||||
/>
|
||||
|
||||
<!-- 线路选择弹窗 -->
|
||||
@ -56,111 +38,54 @@
|
||||
<SettingsModal
|
||||
v-if="showSettings"
|
||||
@close="showSettings = false"
|
||||
@reload="loadChannels"
|
||||
@reload="reloadChannels"
|
||||
/>
|
||||
|
||||
<!-- 遥控数字输入显示 -->
|
||||
<InputPanel @complete="handleRemoteInputComplete" />
|
||||
|
||||
<!-- 调试面板 -->
|
||||
<DebugPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStorage } from './composables/useStorage.js';
|
||||
import { useUI } from './composables/useUI.js';
|
||||
import DebugPanel from './components/DebugPanel.vue';
|
||||
import VideoPlayer from './components/VideoPlayer.vue';
|
||||
import LeftPanel from './components/Layout/LeftPanel.vue';
|
||||
import BottomPanel from './components/Layout/BottomPanel.vue';
|
||||
import SourceModal from './components/Modals/SourceModal.vue';
|
||||
import SettingsModal from './components/Modals/SettingsModal.vue';
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useStorage } from "./composables/useStorage.js";
|
||||
import { useUI } from "./composables/useUI.js";
|
||||
import { useFavorites } from "./composables/useFavorites.js";
|
||||
import { useHistory } from "./composables/useHistory.js";
|
||||
import { useChannels } from "./composables/useChannels.js";
|
||||
import DebugPanel from "./components/Layout/DebugPanel.vue";
|
||||
import VideoPlayer from "./components/VideoPlayer.vue";
|
||||
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
|
||||
// Storage(底层存储接口,只负责数据存取)
|
||||
const storage = useStorage();
|
||||
|
||||
// UI 状态
|
||||
const {
|
||||
leftPanelVisible,
|
||||
bottomPanelVisible,
|
||||
showBottomPanel,
|
||||
hideBottomPanel,
|
||||
onBottomInteraction
|
||||
} = useUI();
|
||||
const { showLeftPanel, showBottomPanel, hideLeftPanel } = useUI();
|
||||
|
||||
// 业务逻辑 hooks(内部使用 useStorage 持久化)
|
||||
const { toggleFavorite, isFavorite } = useFavorites();
|
||||
const { addToHistory } = useHistory();
|
||||
const { channels, loadChannels } = useChannels();
|
||||
|
||||
const showDebug = ref(false);
|
||||
const showSourceSelector = ref(false);
|
||||
const showSettings = ref(false);
|
||||
|
||||
// 播放器
|
||||
const playerRef = ref(null);
|
||||
const currentUrl = ref('');
|
||||
const currentUrl = ref("");
|
||||
const currentSourceIndex = ref(0);
|
||||
|
||||
// 频道数据
|
||||
const channels = ref([]);
|
||||
const groups = computed(() => [...new Set(channels.value.map(c => c.group))]);
|
||||
// 播放状态
|
||||
const currentChannel = ref(null);
|
||||
const loadingChannels = ref(false);
|
||||
|
||||
// 收藏(从 storage 加载)
|
||||
const favorites = ref(new Set());
|
||||
async function loadFavorites() {
|
||||
const subs = await storage.getSubscriptions();
|
||||
// 从订阅源配置中读取收藏列表
|
||||
const favSub = subs.find(s => s.id === '__favorites__');
|
||||
if (favSub && favSub.data) {
|
||||
favorites.value = new Set(favSub.data);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFavorites() {
|
||||
const subs = await storage.getSubscriptions();
|
||||
const favIndex = subs.findIndex(s => s.id === '__favorites__');
|
||||
const favSub = {
|
||||
id: '__favorites__',
|
||||
name: '收藏',
|
||||
type: 'internal',
|
||||
data: Array.from(favorites.value)
|
||||
};
|
||||
if (favIndex >= 0) {
|
||||
subs[favIndex] = favSub;
|
||||
} else {
|
||||
subs.push(favSub);
|
||||
}
|
||||
await storage.setSubscriptions(subs);
|
||||
}
|
||||
|
||||
function isFavorite(channelId) {
|
||||
return favorites.value.has(channelId);
|
||||
}
|
||||
|
||||
async function toggleFavorite(channelId) {
|
||||
if (!channelId) return;
|
||||
if (favorites.value.has(channelId)) {
|
||||
favorites.value.delete(channelId);
|
||||
} else {
|
||||
favorites.value.add(channelId);
|
||||
}
|
||||
await saveFavorites();
|
||||
}
|
||||
|
||||
// 最近播放
|
||||
const recentChannels = ref([]);
|
||||
async function addToRecent(channel) {
|
||||
if (!channel) return;
|
||||
// 去重并置顶
|
||||
recentChannels.value = [
|
||||
channel,
|
||||
...recentChannels.value.filter(c => c.id !== channel.id)
|
||||
].slice(0, 20); // 保留最近20个
|
||||
|
||||
// 持久化
|
||||
await storage.set('recentChannels', recentChannels.value);
|
||||
}
|
||||
|
||||
async function loadRecent() {
|
||||
const recent = await storage.get('recentChannels');
|
||||
if (recent) {
|
||||
recentChannels.value = recent;
|
||||
}
|
||||
}
|
||||
|
||||
// 播放频道
|
||||
async function handlePlay(channel) {
|
||||
@ -169,15 +94,16 @@ async function handlePlay(channel) {
|
||||
currentChannel.value = channel;
|
||||
|
||||
// 测速排序后选择最佳线路
|
||||
const sortedUrls = await sortUrlsBySpeed(channel.urls);
|
||||
currentUrl.value = sortedUrls[0] || channel.urls[0];
|
||||
// const sortedUrls = await sortUrlsBySpeed(channel.urls);
|
||||
// currentUrl.value = sortedUrls[0] || channel.urls[0];
|
||||
currentUrl.value = channel.urls[0];
|
||||
currentSourceIndex.value = 0;
|
||||
|
||||
showBottomPanel();
|
||||
hideLeftPanel();
|
||||
|
||||
// 添加到最近播放
|
||||
await addToRecent(channel);
|
||||
// 添加到播放历史
|
||||
await addToHistory(channel);
|
||||
}
|
||||
|
||||
// 测速排序
|
||||
@ -200,19 +126,19 @@ async function sortUrlsBySpeed(urls) {
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
mode: 'no-cors' // 允许跨域
|
||||
mode: "no-cors", // 允许跨域
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
const validity = {
|
||||
status: 'online',
|
||||
status: "online",
|
||||
checkedAt: Date.now(),
|
||||
latency,
|
||||
failCount: 0
|
||||
failCount: 0,
|
||||
};
|
||||
|
||||
validityMap.value.set(url, validity);
|
||||
@ -225,10 +151,10 @@ async function sortUrlsBySpeed(urls) {
|
||||
const failCount = (cached?.failCount || 0) + 1;
|
||||
|
||||
const validity = {
|
||||
status: failCount >= 3 ? 'offline' : 'unknown',
|
||||
status: failCount >= 3 ? "offline" : "unknown",
|
||||
checkedAt: Date.now(),
|
||||
latency: Infinity,
|
||||
failCount
|
||||
failCount,
|
||||
};
|
||||
|
||||
validityMap.value.set(url, validity);
|
||||
@ -236,17 +162,17 @@ async function sortUrlsBySpeed(urls) {
|
||||
|
||||
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;
|
||||
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);
|
||||
return results.map((r) => r.url);
|
||||
}
|
||||
|
||||
// 切换线路
|
||||
@ -268,7 +194,7 @@ async function handlePlayError() {
|
||||
const nextIndex = (currentSourceIndex.value + 1) % urls.length;
|
||||
if (nextIndex === 0) {
|
||||
// 所有线路都试过了
|
||||
console.error('所有线路都失败');
|
||||
console.error("所有线路都失败");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -280,142 +206,29 @@ async function handlePlayError() {
|
||||
const cached = validityMap.value.get(url) || {};
|
||||
validityMap.value.set(url, {
|
||||
...cached,
|
||||
status: 'offline',
|
||||
failCount: (cached.failCount || 0) + 1
|
||||
status: "offline",
|
||||
failCount: (cached.failCount || 0) + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// 回看(TODO)
|
||||
function handleLookback(program) {
|
||||
console.log('回看:', program);
|
||||
// TODO: 实现回看逻辑
|
||||
}
|
||||
|
||||
// 隐藏左侧面板
|
||||
function hideLeftPanel() {
|
||||
leftPanelVisible.value = false;
|
||||
}
|
||||
|
||||
// 加载频道
|
||||
async function loadChannels(force = false) {
|
||||
if (loadingChannels.value) return;
|
||||
loadingChannels.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;
|
||||
loadingChannels.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 {
|
||||
loadingChannels.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取频道数据
|
||||
async function fetchChannelData() {
|
||||
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://iptv.proxy.liyanyan.work/result.txt');
|
||||
if (response.ok) {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('网络加载失败:', e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析频道数据
|
||||
function parseChannelData(text) {
|
||||
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({
|
||||
id: `${currentGroup}_${name}`,
|
||||
name,
|
||||
group: currentGroup,
|
||||
urls: [url],
|
||||
logo: '',
|
||||
epgId: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
// 后台刷新频道
|
||||
async function refreshChannelsInBackground() {
|
||||
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);
|
||||
}
|
||||
// 重新加载频道(供设置面板调用)
|
||||
async function reloadChannels() {
|
||||
await loadChannels(true);
|
||||
}
|
||||
|
||||
// 节目信息(TODO: 实际从 EPG 获取)
|
||||
const currentProgramTitle = ref('精彩节目');
|
||||
const currentProgramTitle = ref("精彩节目");
|
||||
const programProgress = ref(0);
|
||||
const currentTime = ref('--:--');
|
||||
const totalTime = ref('--:--');
|
||||
const currentTime = ref("--:--");
|
||||
const totalTime = ref("--:--");
|
||||
|
||||
// 更新节目信息
|
||||
function updateProgramInfo() {
|
||||
const now = new Date();
|
||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
currentTime.value = now.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
// TODO: 从 EPG 获取当前节目
|
||||
}
|
||||
|
||||
@ -430,10 +243,8 @@ async function loadValidityCache() {
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await loadFavorites();
|
||||
await loadRecent();
|
||||
await loadValidityCache();
|
||||
await loadChannels();
|
||||
// 频道数据由 useChannels 自动加载
|
||||
|
||||
// 定时更新节目信息
|
||||
setInterval(updateProgramInfo, 60000);
|
||||
@ -445,7 +256,7 @@ watch(currentChannel, async (channel) => {
|
||||
if (!channel || !channel.urls) return;
|
||||
|
||||
// 检查是否需要测速
|
||||
const needCheck = channel.urls.some(url => {
|
||||
const needCheck = channel.urls.some((url) => {
|
||||
const cached = validityMap.value.get(url);
|
||||
return !cached || Date.now() - cached.checkedAt > 60 * 60 * 1000;
|
||||
});
|
||||
@ -455,6 +266,19 @@ watch(currentChannel, async (channel) => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@ -467,29 +291,4 @@ watch(currentChannel, async (channel) => {
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.debug-toggle {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 200;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.debug-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<div class="debug-panel">
|
||||
<div class="debug-header">
|
||||
<span>调试信息</span>
|
||||
<button @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="debug-content">
|
||||
<p>调试功能开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.debug-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #ff6b6b;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
z-index: 9999;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.debug-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.debug-header button {
|
||||
background: #333;
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user