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

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>