refactor: 重构 App.vue 使用新架构

- 使用 useChannels 替代内联频道逻辑
- 使用 useFavorites/useHistory 替代内联收藏/历史
- 移除 channels/groups 计算属性
- 简化 props 传递到子组件
- 删除无用方法 (fetchChannelData, parseChannelData 等)
This commit is contained in:
李岩岩 2026-02-09 00:28:02 +08:00
parent f7a8e3524c
commit c0428c5d3d
2 changed files with 101 additions and 345 deletions

View File

@ -1,34 +1,17 @@
<template> <template>
<div class="iptv-app"> <div class="iptv-app">
<!-- 调试面板 -->
<DebugPanel v-if="showDebug" @close="showDebug = false" />
<button v-else class="debug-toggle" @click="showDebug = true">🐛</button>
<!-- 全屏视频播放器 --> <!-- 全屏视频播放器 -->
<VideoPlayer <VideoPlayer ref="playerRef" :url="currentUrl" @error="handlePlayError" />
ref="playerRef"
:url="currentUrl"
:title="currentChannel?.name"
class="video-layer"
@error="handlePlayError"
/>
<!-- 左侧面板四栏 --> <!-- 左侧面板四栏 -->
<LeftPanel <LeftPanel
:visible="leftPanelVisible"
:channels="channels"
:groups="groups"
:current-channel="currentChannel" :current-channel="currentChannel"
:favorites="favorites"
:validity-map="validityMap" :validity-map="validityMap"
@close="hideLeftPanel"
@play="handlePlay" @play="handlePlay"
@lookback="handleLookback"
/> />
<!-- 底部信息栏 --> <!-- 底部信息栏 -->
<BottomPanel <BottomPanel
:visible="bottomPanelVisible"
:channel="currentChannel" :channel="currentChannel"
:current-source-index="currentSourceIndex" :current-source-index="currentSourceIndex"
:current-program="currentProgramTitle" :current-program="currentProgramTitle"
@ -39,7 +22,6 @@
@favorite="toggleFavorite(currentChannel?.id)" @favorite="toggleFavorite(currentChannel?.id)"
@switch-source="showSourceSelector = true" @switch-source="showSourceSelector = true"
@settings="showSettings = true" @settings="showSettings = true"
@interaction="onBottomInteraction"
/> />
<!-- 线路选择弹窗 --> <!-- 线路选择弹窗 -->
@ -56,111 +38,54 @@
<SettingsModal <SettingsModal
v-if="showSettings" v-if="showSettings"
@close="showSettings = false" @close="showSettings = false"
@reload="loadChannels" @reload="reloadChannels"
/> />
<!-- 遥控数字输入显示 -->
<InputPanel @complete="handleRemoteInputComplete" />
<!-- 调试面板 -->
<DebugPanel />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { ref, onMounted, watch } from "vue";
import { useStorage } from './composables/useStorage.js'; import { useStorage } from "./composables/useStorage.js";
import { useUI } from './composables/useUI.js'; import { useUI } from "./composables/useUI.js";
import DebugPanel from './components/DebugPanel.vue'; import { useFavorites } from "./composables/useFavorites.js";
import VideoPlayer from './components/VideoPlayer.vue'; import { useHistory } from "./composables/useHistory.js";
import LeftPanel from './components/Layout/LeftPanel.vue'; import { useChannels } from "./composables/useChannels.js";
import BottomPanel from './components/Layout/BottomPanel.vue'; import DebugPanel from "./components/Layout/DebugPanel.vue";
import SourceModal from './components/Modals/SourceModal.vue'; import VideoPlayer from "./components/VideoPlayer.vue";
import SettingsModal from './components/Modals/SettingsModal.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(); const storage = useStorage();
// UI // UI
const { const { showLeftPanel, showBottomPanel, hideLeftPanel } = useUI();
leftPanelVisible,
bottomPanelVisible, // hooks使 useStorage
showBottomPanel, const { toggleFavorite, isFavorite } = useFavorites();
hideBottomPanel, const { addToHistory } = useHistory();
onBottomInteraction const { channels, loadChannels } = useChannels();
} = useUI();
const showDebug = ref(false);
const showSourceSelector = ref(false); const showSourceSelector = ref(false);
const showSettings = ref(false); const showSettings = ref(false);
// //
const playerRef = ref(null); const playerRef = ref(null);
const currentUrl = ref(''); const currentUrl = ref("");
const currentSourceIndex = ref(0); const currentSourceIndex = ref(0);
// //
const channels = ref([]);
const groups = computed(() => [...new Set(channels.value.map(c => c.group))]);
const currentChannel = ref(null); 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) { async function handlePlay(channel) {
@ -169,15 +94,16 @@ async function handlePlay(channel) {
currentChannel.value = channel; currentChannel.value = channel;
// 线 // 线
const sortedUrls = await sortUrlsBySpeed(channel.urls); // const sortedUrls = await sortUrlsBySpeed(channel.urls);
currentUrl.value = sortedUrls[0] || channel.urls[0]; // currentUrl.value = sortedUrls[0] || channel.urls[0];
currentUrl.value = channel.urls[0];
currentSourceIndex.value = 0; currentSourceIndex.value = 0;
showBottomPanel(); showBottomPanel();
hideLeftPanel(); hideLeftPanel();
// //
await addToRecent(channel); await addToHistory(channel);
} }
// //
@ -200,19 +126,19 @@ async function sortUrlsBySpeed(urls) {
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, { const response = await fetch(url, {
method: 'HEAD', method: "HEAD",
signal: controller.signal, signal: controller.signal,
mode: 'no-cors' // mode: "no-cors", //
}); });
clearTimeout(timeout); clearTimeout(timeout);
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const validity = { const validity = {
status: 'online', status: "online",
checkedAt: Date.now(), checkedAt: Date.now(),
latency, latency,
failCount: 0 failCount: 0,
}; };
validityMap.value.set(url, validity); validityMap.value.set(url, validity);
@ -225,10 +151,10 @@ async function sortUrlsBySpeed(urls) {
const failCount = (cached?.failCount || 0) + 1; const failCount = (cached?.failCount || 0) + 1;
const validity = { const validity = {
status: failCount >= 3 ? 'offline' : 'unknown', status: failCount >= 3 ? "offline" : "unknown",
checkedAt: Date.now(), checkedAt: Date.now(),
latency: Infinity, latency: Infinity,
failCount failCount,
}; };
validityMap.value.set(url, validity); validityMap.value.set(url, validity);
@ -236,17 +162,17 @@ async function sortUrlsBySpeed(urls) {
return { url, ...validity }; return { url, ...validity };
} }
}) }),
); );
// 线线 // 线线
results.sort((a, b) => { 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 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; const nextIndex = (currentSourceIndex.value + 1) % urls.length;
if (nextIndex === 0) { if (nextIndex === 0) {
// 线 // 线
console.error('所有线路都失败'); console.error("所有线路都失败");
return; return;
} }
@ -280,142 +206,29 @@ async function handlePlayError() {
const cached = validityMap.value.get(url) || {}; const cached = validityMap.value.get(url) || {};
validityMap.value.set(url, { validityMap.value.set(url, {
...cached, ...cached,
status: 'offline', status: "offline",
failCount: (cached.failCount || 0) + 1 failCount: (cached.failCount || 0) + 1,
}); });
} }
// TODO //
function handleLookback(program) { async function reloadChannels() {
console.log('回看:', program); await loadChannels(true);
// 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);
}
} }
// TODO: EPG // TODO: EPG
const currentProgramTitle = ref('精彩节目'); const currentProgramTitle = ref("精彩节目");
const programProgress = ref(0); const programProgress = ref(0);
const currentTime = ref('--:--'); const currentTime = ref("--:--");
const totalTime = ref('--:--'); const totalTime = ref("--:--");
// //
function updateProgramInfo() { function updateProgramInfo() {
const now = new Date(); 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 // TODO: EPG
} }
@ -430,10 +243,8 @@ async function loadValidityCache() {
// //
onMounted(async () => { onMounted(async () => {
await loadFavorites();
await loadRecent();
await loadValidityCache(); await loadValidityCache();
await loadChannels(); // useChannels
// //
setInterval(updateProgramInfo, 60000); setInterval(updateProgramInfo, 60000);
@ -445,7 +256,7 @@ watch(currentChannel, async (channel) => {
if (!channel || !channel.urls) return; if (!channel || !channel.urls) return;
// //
const needCheck = channel.urls.some(url => { const needCheck = channel.urls.some((url) => {
const cached = validityMap.value.get(url); const cached = validityMap.value.get(url);
return !cached || Date.now() - cached.checkedAt > 60 * 60 * 1000; return !cached || Date.now() - cached.checkedAt > 60 * 60 * 1000;
}); });
@ -455,6 +266,19 @@ watch(currentChannel, async (channel) => {
sortUrlsBySpeed(channel.urls); 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>
@ -467,29 +291,4 @@ watch(currentChannel, async (channel) => {
background: #000; background: #000;
overflow: hidden; 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> </style>

View File

@ -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>