Compare commits

...

4 Commits

Author SHA1 Message Date
李岩岩
c0428c5d3d refactor: 重构 App.vue 使用新架构
- 使用 useChannels 替代内联频道逻辑
- 使用 useFavorites/useHistory 替代内联收藏/历史
- 移除 channels/groups 计算属性
- 简化 props 传递到子组件
- 删除无用方法 (fetchChannelData, parseChannelData 等)
2026-02-09 00:28:02 +08:00
李岩岩
f7a8e3524c refactor: 重构组件使用新 hooks
- LeftPanel: 整合子组件,使用 useChannels/useGroups/useFavorites
- BottomPanel: 使用 useUI 替代 props
- VideoPlayer: 优化播放器逻辑
- DebugPanel: 移动到 Layout 目录
- InputPanel: 新增遥控输入组件
- useUI: 优化 UI 状态管理
2026-02-09 00:27:55 +08:00
李岩岩
bc4434c93d feat: 新增业务逻辑 composables
- useFavorites: 收藏管理
- useHistory: 播放历史
- useSettings: 用户设置
- useChannels: 频道数据获取和解析
- useGroups: 分组管理
- useChannelFilter: 频道过滤
- useDates: 日期列表
- usePrograms: 节目单管理
- useEvent: 键盘事件
2026-02-09 00:27:46 +08:00
李岩岩
5f8165b236 refactor: 移除 Pinia 和相关无用文件
- 删除 stores/useStore.js (Pinia)
- 删除 components/ConfigPanel.vue
- 删除 Layout/ 子组件 (GroupList, ChannelList, DateList, ProgramList)
2026-02-09 00:27:38 +08:00
26 changed files with 1416 additions and 1708 deletions

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "esbenp.prettier-vscode"]
}

22
.vscode/settings.json vendored
View File

@ -1,10 +1,5 @@
{
"cSpell.words": [
"IPTV",
"iptv",
"liyanyan",
"tauri"
],
"cSpell.words": ["IPTV", "iptv", "liyanyan", "tauri"],
"cSpell.ignorePaths": [
"package-lock.json",
"public/api",
@ -14,5 +9,16 @@
".git/{index,*refs,*HEAD}",
".vscode",
".vscode-insiders"
]
}
],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": false,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

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,9 +22,8 @@
@favorite="toggleFavorite(currentChannel?.id)"
@switch-source="showSourceSelector = true"
@settings="showSettings = true"
@interaction="onBottomInteraction"
/>
<!-- 线路选择弹窗 -->
<SourceModal
v-if="showSourceSelector"
@ -51,139 +33,83 @@
@switch="switchSource"
@close="showSourceSelector = false"
/>
<!-- 设置弹窗 -->
<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) {
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];
// 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);
}
//
async function sortUrlsBySpeed(urls) {
if (!urls || urls.length === 0) return [];
const results = await Promise.all(
urls.map(async (url) => {
//
@ -192,61 +118,61 @@ async function sortUrlsBySpeed(urls) {
// 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',
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);
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',
status: failCount >= 3 ? "offline" : "unknown",
checkedAt: Date.now(),
latency: Infinity,
failCount
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;
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);
}
// 线
@ -260,162 +186,49 @@ function switchSource(source, index) {
//
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('所有线路都失败');
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
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,11 +243,9 @@ async function loadValidityCache() {
//
onMounted(async () => {
await loadFavorites();
await loadRecent();
await loadValidityCache();
await loadChannels();
// useChannels
//
setInterval(updateProgramInfo, 60000);
updateProgramInfo();
@ -443,18 +254,31 @@ onMounted(async () => {
// 线
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;
});
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>
<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,483 +0,0 @@
<template>
<div class="config-panel" v-if="show">
<div class="panel-overlay" @click="$emit('close')"></div>
<div class="panel-content">
<div class="panel-header">
<h2>应用设置</h2>
<button class="close-btn" @click="$emit('close')"></button>
</div>
<!-- 标签页 -->
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-btn"
:class="{ active: currentTab === tab.id }"
@click="currentTab = tab.id"
>
{{ tab.name }}
</button>
</div>
<div class="panel-body">
<!-- 数据源 -->
<div v-if="currentTab === 'source'" class="tab-content">
<div class="setting-group">
<label>数据源类型</label>
<select v-model="config.apiType">
<option value="local">📁 本地文件</option>
<option value="guovin">🌐 在线接口</option>
<option value="custom"> 自定义</option>
</select>
</div>
<div v-if="config.apiType === 'local'" class="setting-group">
<label>选择文件</label>
<select v-model="config.localFile">
<option value="/api/result.txt">result.txt完整</option>
<option value="/api/ipv4/result.txt">IPv4 专线</option>
<option value="/api/ipv6/result.txt">IPv6 专线</option>
</select>
</div>
<div v-if="config.apiType === 'custom'" class="setting-group">
<label>接口地址</label>
<input v-model="config.apiUrl" placeholder="http://..." />
</div>
<div class="setting-group">
<label>EPG 数据源</label>
<select v-model="config.epgType">
<option value="local">📁 本地文件</option>
<option value="custom"> 自定义</option>
</select>
</div>
</div>
<!-- 播放设置 -->
<div v-if="currentTab === 'playback'" class="tab-content">
<div class="setting-group">
<label>自动播放</label>
<div class="toggle-switch">
<input type="checkbox" v-model="config.autoPlay" id="autoplay">
<label for="autoplay"></label>
</div>
</div>
<div class="setting-group">
<label>默认音量 {{ Math.round(config.defaultVolume * 100) }}%</label>
<input
type="range"
min="0"
max="1"
step="0.1"
v-model="config.defaultVolume"
class="slider"
/>
</div>
<div class="setting-group">
<label>检测超时 {{ config.checkTimeout / 1000 }}s</label>
<input
type="range"
min="1000"
max="10000"
step="500"
v-model.number="config.checkTimeout"
class="slider"
/>
</div>
<div class="setting-group">
<label>缓存过期 {{ Math.round(config.cacheExpire / 1000 / 60 / 60) }}h</label>
<input
type="range"
min="3600000"
max="168000000"
step="3600000"
v-model.number="config.cacheExpire"
class="slider"
/>
</div>
</div>
<!-- 外观 -->
<div v-if="currentTab === 'appearance'" class="tab-content">
<div class="setting-group">
<label>主题</label>
<div class="theme-options">
<div
class="theme-card"
:class="{ active: config.theme === 'dark' }"
@click="config.theme = 'dark'"
>
<div class="theme-icon">🌙</div>
<span>深色</span>
</div>
<div
class="theme-card"
:class="{ active: config.theme === 'light' }"
@click="config.theme = 'light'"
>
<div class="theme-icon"></div>
<span>浅色</span>
</div>
<div
class="theme-card"
:class="{ active: config.theme === 'system' }"
@click="config.theme = 'system'"
>
<div class="theme-icon">💻</div>
<span>跟随系统</span>
</div>
</div>
</div>
<div class="shortcuts">
<h4>快捷键</h4>
<p>S 打开设置 | M 菜单 | I 信息 | F 收藏</p>
<p> 切换线路 | 切换频道</p>
</div>
</div>
</div>
<div class="panel-footer">
<button class="btn-primary" @click="saveAndClose">
完成
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useStore } from '../stores/useStore.js'
const props = defineProps({
show: Boolean
})
const emit = defineEmits(['close', 'reload'])
const store = useStore()
const tabs = [
{ id: 'source', name: '数据源' },
{ id: 'playback', name: '播放设置' },
{ id: 'appearance', name: '外观' }
]
const currentTab = ref('source')
const config = ref({
apiType: localStorage.getItem('iptv_api_type') || 'local',
apiUrl: localStorage.getItem('iptv_api_url') || '',
localFile: localStorage.getItem('iptv_local_file') || '/api/result.txt',
epgType: localStorage.getItem('iptv_epg_type') || 'local',
autoPlay: store.settings.autoPlay,
defaultVolume: store.settings.defaultVolume,
checkTimeout: store.settings.checkTimeout || 2000,
cacheExpire: parseInt(localStorage.getItem('iptv_cache_expire')) || (24 * 60 * 60 * 1000),
theme: localStorage.getItem('iptv_theme') || 'dark'
})
watch(config, (newVal) => {
localStorage.setItem('iptv_api_type', newVal.apiType)
localStorage.setItem('iptv_api_url', newVal.apiUrl)
localStorage.setItem('iptv_local_file', newVal.localFile)
localStorage.setItem('iptv_epg_type', newVal.epgType)
localStorage.setItem('iptv_cache_expire', newVal.cacheExpire)
localStorage.setItem('iptv_theme', newVal.theme)
store.updateSetting('autoPlay', newVal.autoPlay)
store.updateSetting('defaultVolume', newVal.defaultVolume)
store.updateSetting('checkTimeout', newVal.checkTimeout)
}, { deep: true })
function saveAndClose() {
emit('reload')
emit('close')
}
</script>
<style scoped>
.config-panel {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
.panel-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
}
.panel-content {
position: relative;
background: #1a1a1a;
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
}
.panel-header h2 {
font-size: 18px;
font-weight: 500;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: #888;
font-size: 18px;
cursor: pointer;
}
.close-btn:hover {
background: #2a2a2a;
color: #fff;
}
/* 标签页 */
.tabs {
display: flex;
padding: 0 20px;
gap: 8px;
border-bottom: 1px solid #2a2a2a;
}
.tab-btn {
flex: 1;
padding: 12px;
background: transparent;
border: none;
color: #888;
font-size: 14px;
cursor: pointer;
border-radius: 8px 8px 0 0;
transition: all 0.2s;
}
.tab-btn:hover {
color: #fff;
background: #2a2a2a;
}
.tab-btn.active {
color: #fff;
background: #2a2a2a;
}
/* 内容区 */
.panel-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.tab-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.setting-group label {
font-size: 13px;
color: #888;
}
select, input {
width: 100%;
padding: 12px;
border: 1px solid #333;
border-radius: 10px;
background: #252525;
color: #fff;
font-size: 14px;
}
select:focus, input:focus {
outline: none;
border-color: #555;
}
/* 开关 */
.toggle-switch {
position: relative;
width: 50px;
height: 28px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch label {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #333;
border-radius: 28px;
transition: 0.3s;
}
.toggle-switch label:before {
position: absolute;
content: "";
height: 22px;
width: 22px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: 0.3s;
}
.toggle-switch input:checked + label {
background: #00d4ff;
}
.toggle-switch input:checked + label:before {
transform: translateX(22px);
}
/* 滑块 */
.slider {
-webkit-appearance: none;
height: 6px;
background: #333;
border-radius: 3px;
padding: 0;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
/* 主题选项 */
.theme-options {
display: flex;
gap: 12px;
}
.theme-card {
flex: 1;
padding: 20px;
background: #252525;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.theme-card:hover {
background: #2a2a2a;
}
.theme-card.active {
border-color: #fff;
background: #2a2a2a;
}
.theme-icon {
font-size: 24px;
}
.theme-card span {
font-size: 13px;
color: #aaa;
}
.theme-card.active span {
color: #fff;
}
/* 快捷键 */
.shortcuts {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #2a2a2a;
}
.shortcuts h4 {
font-size: 13px;
color: #888;
margin-bottom: 12px;
font-weight: normal;
}
.shortcuts p {
font-size: 12px;
color: #666;
line-height: 1.8;
}
/* 底部按钮 */
.panel-footer {
padding: 20px;
border-top: 1px solid #2a2a2a;
}
.btn-primary {
width: 100%;
padding: 14px;
background: #fff;
color: #000;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover {
background: #e0e0e0;
}
</style>

View File

@ -1,10 +1,10 @@
<template>
<Transition name="slide-up">
<div
v-show="visible"
v-show="bottomPanelVisible"
class="bottom-panel"
@mouseenter="onInteraction"
@mousemove="onInteraction"
@mouseenter="showBottomPanel"
@mousemove="showBottomPanel"
>
<!-- 左侧频道信息 -->
<div class="panel-left">
@ -33,7 +33,7 @@
class="action-btn"
:class="{ active: isFavorite }"
@click="handleFavorite"
@mouseenter="onInteraction"
@mouseenter="showBottomPanel"
>
<span class="icon"></span>
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span>
@ -42,7 +42,7 @@
<button
class="action-btn"
@click="handleSwitchSource"
@mouseenter="onInteraction"
@mouseenter="showBottomPanel"
>
<span class="icon"></span>
<span>切换线路</span>
@ -51,7 +51,7 @@
<button
class="action-btn"
@click="handleSettings"
@mouseenter="onInteraction"
@mouseenter="showBottomPanel"
>
<span class="icon"></span>
<span>设置</span>
@ -63,10 +63,6 @@
<script setup>
const props = defineProps({
visible: {
type: Boolean,
default: false
},
channel: {
type: Object,
default: null
@ -97,22 +93,20 @@ const props = defineProps({
}
});
const emit = defineEmits(['favorite', 'switch-source', 'settings', 'interaction']);
const emit = defineEmits(['favorite', 'switch-source', 'settings']);
import { useUI } from "../../composables/useUI.js";
const { bottomPanelVisible, showBottomPanel } = useUI();
// LOGO
function getChannelLogo(name) {
return name ? name.slice(0, 2) : '--';
}
//
function onInteraction() {
emit('interaction');
}
//
function handleFavorite() {
emit('favorite');
onInteraction();
showBottomPanel();
}
// 线

View File

@ -1,160 +0,0 @@
<template>
<div
class="channel-list"
:class="{ 'is-active': isActive }"
>
<div
v-for="channel in channels"
:key="channel.id"
class="channel-item"
:class="{ active: modelValue?.id === channel.id }"
@click="selectChannel(channel)"
>
<div class="channel-logo">
{{ getChannelLogo(channel.name) }}
</div>
<div class="channel-info">
<div class="channel-name">{{ channel.name }}</div>
<div class="channel-meta">
<span class="source-count">
{{ getValidCount(channel) }}/{{ channel.urls?.length || 0 }}线路
</span>
<span v-if="isFavorite(channel.id)" class="favorite-icon"></span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Object,
default: null
},
channels: {
type: Array,
default: () => []
},
isActive: {
type: Boolean,
default: false
},
favorites: {
type: Set,
default: () => new Set()
},
validityMap: {
type: Map,
default: () => new Map()
}
});
const emit = defineEmits(['update:modelValue', 'select']);
const platform = import.meta.env.VITE_PLATFORM || 'web';
const isTV = platform === 'tv';
// LOGO
function getChannelLogo(name) {
return name.slice(0, 2);
}
//
function isFavorite(channelId) {
return props.favorites.has(channelId);
}
// 线
function getValidCount(channel) {
if (!channel.urls) return 0;
return channel.urls.filter(url => {
const validity = props.validityMap.get(url);
return validity && validity.status === 'online';
}).length;
}
//
function selectChannel(channel) {
emit('update:modelValue', channel);
emit('select', channel);
}
</script>
<style scoped>
.channel-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.85);
padding: 8px;
}
.channel-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
}
.channel-item:hover,
.channel-item.active {
background: rgba(255, 255, 255, 0.1);
}
.channel-item.active {
background: rgba(255, 255, 255, 0.2);
}
.channel-logo {
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
font-weight: bold;
}
.channel-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.channel-name {
font-size: 14px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.favorite-icon {
color: #ffd700;
}
/* TV 模式焦点样式 */
.is-active .channel-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,108 +0,0 @@
<template>
<div
class="date-list"
:class="{ 'is-active': isActive }"
>
<div
v-for="date in dates"
:key="date.value"
class="date-item"
:class="{ active: modelValue === date.value }"
@click="selectDate(date.value)"
>
<div class="date-day">{{ date.day }}</div>
<div class="date-label">{{ date.label }}</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
isActive: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'select']);
// ...
const dates = computed(() => {
const list = [];
const today = new Date();
const labels = ['今天', '明天', '后天'];
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 ? labels[i] : `${date.getMonth() + 1}${date.getDate()}`;
list.push({ value, day, label });
}
return list;
});
//
function selectDate(dateValue) {
emit('update:modelValue', dateValue);
emit('select', dateValue);
}
</script>
<style scoped>
.date-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.8);
padding: 8px;
}
.date-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 8px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
}
.date-item:hover,
.date-item.active {
background: rgba(255, 255, 255, 0.1);
}
.date-item.active {
background: rgba(255, 255, 255, 0.2);
}
.date-day {
font-size: 16px;
font-weight: bold;
color: #fff;
}
.date-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
/* TV 模式焦点样式 */
.is-active .date-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,8 +1,8 @@
<template>
<div class="debug-panel">
<div class="debug-panel" v-if="showDebug">
<div class="debug-header">
<span>调试信息</span>
<button @click="$emit('close')">×</button>
<button @click="handleClosePanel">×</button>
</div>
<div class="debug-content">
<p>调试功能开发中...</p>
@ -10,6 +10,31 @@
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useEvent, useKeyEvent } from "../../composables/useEvent.js";
const input = ref("");
const showDebug = ref(false);
const handleClosePanel = () => {
showDebug.value = false;
input.value = "";
};
onMounted(() => {
useEvent("keyup", (e) => {
input.value += e.key;
if (input.value.includes("bug")) {
showDebug.value = true;
}
});
useKeyEvent("Escape", () => {
if (showDebug.value) {
handleClosePanel();
}
});
});
</script>
<style scoped>
.debug-panel {
position: fixed;

View File

@ -1,169 +0,0 @@
<template>
<div
class="group-list"
:class="{ 'is-active': isActive }"
>
<!-- 置顶分组 -->
<div class="group-section pinned">
<div
v-for="group in pinnedGroups"
:key="group.id"
class="group-item"
:class="{ active: modelValue === group.id }"
@click="selectGroup(group.id)"
>
<span class="group-icon">{{ group.icon }}</span>
<div class="group-info">
<span class="group-name">{{ group.name }}</span>
<span class="group-count">{{ group.count }}</span>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="divider"></div>
<!-- 普通分组 -->
<div class="group-section normal">
<div
v-for="group in normalGroups"
:key="group.id"
class="group-item"
:class="{ active: modelValue === group.id }"
@click="selectGroup(group.id)"
>
<span class="group-icon">{{ group.icon }}</span>
<div class="group-info">
<span class="group-name">{{ group.name }}</span>
<span class="group-count">{{ group.count }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
groups: {
type: Array,
default: () => []
},
isActive: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'select']);
//
const pinnedGroups = computed(() => {
return [
{ id: 'recent', name: '最近播放', icon: '⏱', count: 0 }, // TODO:
{ id: 'favorite', name: '收藏', icon: '❤️', count: 0 }, // TODO:
];
});
//
const normalGroups = computed(() => {
return props.groups.map(group => ({
id: group,
name: group,
icon: getGroupIcon(group),
count: 0 // TODO:
}));
});
//
function getGroupIcon(groupName) {
if (groupName.includes('央视')) return '📺';
if (groupName.includes('卫视')) return '📡';
if (groupName.includes('体育')) return '⚽';
if (groupName.includes('电影')) return '🎬';
if (groupName.includes('少儿')) return '👶';
return '📺';
}
//
function selectGroup(groupId) {
emit('update:modelValue', groupId);
emit('select', groupId);
}
</script>
<style scoped>
.group-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.9);
padding: 8px 0;
}
.group-section {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px;
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 8px;
}
.group-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.group-item:hover,
.group-item.active {
background: rgba(255, 255, 255, 0.1);
}
.group-item.active {
background: rgba(255, 255, 255, 0.2);
}
.group-icon {
font-size: 20px;
width: 24px;
text-align: center;
}
.group-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-name {
font-size: 14px;
color: #fff;
}
.group-count {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
/* TV 模式焦点样式 */
.is-active .group-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<Transition name="fade">
<div v-if="visible" class="remote-input-panel">
<span class="input-text">{{ displayValue }}</span>
</div>
</Transition>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useEvent } from "../../composables/useEvent.js";
import { debounce } from "../../utils/common.js";
const emit = defineEmits(["complete"]);
//
const visible = ref(false);
const inputValue = ref([]);
//
const displayValue = computed(() => {
return inputValue.value.join("");
});
//
const closePanel = debounce(() => {
visible.value = false;
inputValue.value = [];
emit("complete", displayValue.value);
}, 3000);
//
const handleDigit = (digit) => {
//
visible.value = true;
if (inputValue.value.length == 3) {
// 3
inputValue.value.shift();
}
//
inputValue.value.push(digit);
//
closePanel();
};
onMounted(() => {
// 0-9
useEvent("keyup", (e) => {
if (e.key >= "0" && e.key <= "9") {
handleDigit(e.key);
}
});
});
</script>
<style scoped>
.remote-input-panel {
position: fixed;
top: 20px;
right: 20px;
min-width: 60px;
height: 44px;
padding: 0 16px;
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
z-index: 160;
backdrop-filter: blur(4px);
}
.input-text {
font-size: 24px;
font-weight: bold;
color: #fff;
font-family: monospace;
letter-spacing: 4px;
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -1,188 +1,245 @@
<template>
<Transition name="slide-left">
<div
v-show="visible"
<div
v-show="leftPanelVisible"
class="left-panel"
:class="{ 'tv-mode': isTV }"
>
<!-- 第一栏分组列表 -->
<div
class="column column-1"
:class="{ active: activeColumn === 0 }"
>
<GroupList
v-model="selectedGroup"
:groups="groups"
:is-active="activeColumn === 0"
@select="onGroupSelect"
/>
<div class="column column-1" :class="{ active: activeColumn === 0 }">
<div class="group-list" :class="{ 'is-active': activeColumn === 0 }">
<!-- 置顶分组 -->
<div class="group-section pinned">
<div
v-for="group in pinnedGroups"
:key="group.id"
class="group-item"
:class="{ active: selectedGroup === group.id }"
@click="onGroupSelect(group.id)"
>
<span class="group-icon">{{ group.icon }}</span>
<div class="group-info">
<span class="group-name">{{ group.name }}</span>
<span class="group-count">{{ group.count }}</span>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="divider"></div>
<!-- 普通分组 -->
<div class="group-section normal">
<div
v-for="group in normalGroups"
:key="group.id"
class="group-item"
:class="{ active: selectedGroup === group.id }"
@click="onGroupSelect(group.id)"
>
<span class="group-icon">{{ group.icon }}</span>
<div class="group-info">
<span class="group-name">{{ group.name }}</span>
<span class="group-count">{{ group.count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 第二栏频道列表 -->
<div
class="column column-2"
:class="{ active: activeColumn === 1 }"
>
<ChannelList
v-model="selectedChannel"
:channels="filteredChannels"
:is-active="activeColumn === 1"
:favorites="favorites"
:validity-map="validityMap"
@select="onChannelSelect"
/>
<div class="column column-2" :class="{ active: activeColumn === 1 }">
<div class="channel-list" :class="{ 'is-active': activeColumn === 1 }">
<div
v-for="channel in filteredChannels"
:key="channel.id"
class="channel-item"
:class="{ active: selectedChannel?.id === channel.id }"
@click="onChannelSelect(channel)"
>
<div class="channel-logo">
{{ getChannelLogo(channel.name) }}
</div>
<div class="channel-info">
<div class="channel-name">{{ channel.name }}</div>
<div class="channel-meta">
<span class="source-count">
{{ getValidCount(channel) }}/{{
channel.urls?.length || 0
}}线路
</span>
<span v-if="isFavorite(channel.id)" class="favorite-icon"
></span
>
</div>
</div>
</div>
</div>
</div>
<!-- 第三栏日期列表 -->
<div
class="column column-3"
:class="{ active: activeColumn === 2 }"
>
<DateList
v-model="selectedDate"
:is-active="activeColumn === 2"
@select="onDateSelect"
/>
<div class="column column-3" :class="{ active: activeColumn === 2 }">
<div class="date-list" :class="{ 'is-active': activeColumn === 2 }">
<div
v-for="date in dates"
:key="date.value"
class="date-item"
:class="{ active: selectedDate === date.value }"
@click="onDateSelect(date.value)"
>
<div class="date-day">{{ date.day }}</div>
<div class="date-label">{{ date.label }}</div>
</div>
</div>
</div>
<!-- 第四栏节目单列表 -->
<div
class="column column-4"
:class="{ active: activeColumn === 3 }"
>
<ProgramList
v-model="selectedProgram"
:programs="programs"
:is-active="activeColumn === 3"
@select="onProgramSelect"
/>
<div class="column column-4" :class="{ active: activeColumn === 3 }">
<div class="program-list" :class="{ 'is-active': activeColumn === 3 }">
<div
v-for="program in programs"
:key="program.id"
class="program-item"
:class="{
active: selectedProgram?.id === program.id,
current: program.isCurrent,
}"
@click="onProgramSelect(program)"
>
<div class="program-time">{{ program.time }}</div>
<div class="program-title">
{{ program.title }}
<span v-if="program.isCurrent" class="current-badge">当前</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import GroupList from './GroupList.vue';
import ChannelList from './ChannelList.vue';
import DateList from './DateList.vue';
import ProgramList from './ProgramList.vue';
import { useUI } from '../../composables/useUI.js';
import { computed, watch } from "vue";
import { useUI } from "../../composables/useUI.js";
import { useKeyEvent } from "../../composables/useEvent.js";
import { useGroups } from "../../composables/useGroups.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({
visible: {
type: Boolean,
default: false
},
channels: {
type: Array,
default: () => []
},
groups: {
type: Array,
default: () => []
},
currentChannel: {
type: Object,
default: null
},
favorites: {
type: Set,
default: () => new Set()
default: null,
},
validityMap: {
type: Map,
default: () => new Map()
}
default: () => new Map(),
},
});
const emit = defineEmits(['close', 'play', 'lookback']);
const emit = defineEmits(["play"]);
const { isTV, currentActiveColumn, moveColumn } = useUI();
const { isTV, leftPanelVisible, hideLeftPanel, currentActiveColumn } = useUI();
const activeColumn = computed(() => currentActiveColumn.value);
//
const selectedGroup = ref('');
const selectedChannel = ref(null);
const selectedDate = ref('');
const selectedProgram = ref(null);
// hooks
const { favorites } = useFavorites();
const { channels, groups } = useChannels();
//
const filteredChannels = computed(() => {
if (!selectedGroup.value) return props.channels;
//
if (selectedGroup.value === 'recent') {
// TODO:
return [];
}
if (selectedGroup.value === 'favorite') {
// TODO:
return props.channels.filter(c => props.favorites.has(c.id));
}
return props.channels.filter(c => c.group === selectedGroup.value);
// ===== =====
const {
selectedGroup,
pinnedGroups,
normalGroups,
selectGroup,
setGroups,
initSelectedGroup,
} = useGroups();
// ===== =====
const {
selectedChannel,
filteredChannels,
selectChannel,
setChannels,
setSelectedGroup: setChannelFilterGroup,
setFavorites,
setValidityMap,
getFirstChannel,
getChannelLogo,
isFavorite,
getValidCount,
} = 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);
});
// TODO: EPG
const programs = ref([
{ 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 },
]);
//
watch(() => props.visible, (val) => {
watch(leftPanelVisible, (val) => {
if (val && props.currentChannel) {
// ///
selectedGroup.value = props.currentChannel.group || props.groups[0] || '';
selectedChannel.value = props.currentChannel;
selectedDate.value = new Date().toISOString().split('T')[0];
// TODO:
initSelectedGroup(props.currentChannel, groups.value[0]);
selectChannel(props.currentChannel);
initToday();
}
});
//
function onGroupSelect(groupId) {
selectGroup(groupId);
//
const firstChannel = filteredChannels.value[0];
const firstChannel = getFirstChannel();
if (firstChannel) {
selectedChannel.value = firstChannel;
selectChannel(firstChannel);
}
}
//
function onChannelSelect(channel) {
//
emit('play', channel);
//
emit('close');
selectChannel(channel);
emit("play", channel);
hideLeftPanel();
}
//
function onDateSelect(dateValue) {
//
// TODO:
selectDate(dateValue);
loadProgramsByDate(dateValue);
}
//
function onProgramSelect(program) {
// TODO:
emit('lookback', program);
//
emit('close');
selectProgram(program);
// TODO:
hideLeftPanel();
}
// TV
watch(activeColumn, (val) => {
//
});
//
defineExpose({
moveColumn,
});
useKeyEvent("Escape", hideLeftPanel);
</script>
<style scoped>
@ -234,4 +291,231 @@ defineExpose({
.slide-left-leave-to {
transform: translateX(-100%);
}
/* ===== 分组列表样式 ===== */
.group-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.9);
padding: 8px 0;
}
.group-section {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px;
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 8px;
}
.group-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.group-item:hover,
.group-item.active {
background: rgba(255, 255, 255, 0.2);
}
.group-icon {
font-size: 20px;
width: 24px;
text-align: center;
}
.group-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-name {
font-size: 14px;
color: #fff;
}
.group-count {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
/* ===== 频道列表样式 ===== */
.channel-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.85);
padding: 8px;
}
.channel-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
}
.channel-item:hover,
.channel-item.active {
background: rgba(255, 255, 255, 0.2);
}
.channel-logo {
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
font-weight: bold;
}
.channel-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.channel-name {
font-size: 14px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.favorite-icon {
color: #ffd700;
}
/* ===== 日期列表样式 ===== */
.date-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.8);
padding: 8px;
}
.date-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 8px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
}
.date-item:hover,
.date-item.active {
background: rgba(255, 255, 255, 0.2);
}
.date-day {
font-size: 16px;
font-weight: bold;
color: #fff;
}
.date-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
/* ===== 节目单列表样式 ===== */
.program-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.75);
padding: 8px;
}
.program-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
border-left: 3px solid transparent;
}
.program-item:hover,
.program-item.active {
background: rgba(255, 255, 255, 0.2);
}
.program-item.active {
border-left-color: #fff;
}
.program-item.current {
border-left-color: #ff6b6b;
}
.program-time {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
font-family: monospace;
}
.program-title {
font-size: 14px;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.current-badge {
font-size: 10px;
background: #ff6b6b;
color: #fff;
padding: 2px 6px;
border-radius: 4px;
}
/* TV 模式焦点样式 */
.is-active .group-item:focus,
.is-active .channel-item:focus,
.is-active .date-item:focus,
.is-active .program-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,112 +0,0 @@
<template>
<div
class="program-list"
:class="{ 'is-active': isActive }"
>
<div
v-for="program in programs"
:key="program.id"
class="program-item"
:class="{
active: modelValue?.id === program.id,
current: program.isCurrent
}"
@click="selectProgram(program)"
>
<div class="program-time">{{ program.time }}</div>
<div class="program-title">
{{ program.title }}
<span v-if="program.isCurrent" class="current-badge">当前</span>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Object,
default: null
},
programs: {
type: Array,
default: () => []
},
isActive: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'select']);
//
function selectProgram(program) {
emit('update:modelValue', program);
emit('select', program);
}
</script>
<style scoped>
.program-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.75);
padding: 8px;
}
.program-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
border-left: 3px solid transparent;
}
.program-item:hover,
.program-item.active {
background: rgba(255, 255, 255, 0.1);
}
.program-item.active {
background: rgba(255, 255, 255, 0.2);
border-left-color: #fff;
}
.program-item.current {
border-left-color: #ff6b6b;
}
.program-time {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
font-family: monospace;
}
.program-title {
font-size: 14px;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.current-badge {
font-size: 10px;
background: #ff6b6b;
color: #fff;
padding: 2px 6px;
border-radius: 4px;
}
/* TV 模式焦点样式 */
.is-active .program-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,13 +1,7 @@
<template>
<div class="video-player">
<video
ref="videoRef"
class="video-element"
controls
autoplay
playsinline
></video>
<video ref="videoRef" class="video-element" autoplay playsinline></video>
<!-- 错误提示 -->
<div v-if="error" class="error-overlay">
<div class="error-icon">!</div>
@ -18,90 +12,92 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import Hls from 'hls.js'
import { ref, onMounted, onUnmounted, watch } from "vue";
import Hls from "hls.js";
const props = defineProps({
url: String,
title: String
})
});
const emit = defineEmits(['error', 'ready'])
const emit = defineEmits(["error", "ready"]);
const videoRef = ref(null)
const error = ref(null)
let hls = null
const videoRef = ref(null);
const error = ref(null);
let hls = null;
const initPlayer = () => {
if (!props.url) return
error.value = null
const video = videoRef.value
if (!props.url) return;
error.value = null;
const video = videoRef.value;
// HLS
if (hls) {
hls.destroy()
hls = null
hls.destroy();
hls = null;
}
const isHLS = props.url.includes('.m3u8')
const isHLS = props.url.includes(".m3u8");
if (isHLS && Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
maxBufferLength: 30,
})
hls.loadSource(props.url)
hls.attachMedia(video)
});
hls.loadSource(props.url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(e => console.log('播放被阻止:', e))
})
video.play().catch((e) => console.log("播放被阻止:", e));
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
error.value = data.type
emit('error', { type: data.type, fatal: true })
error.value = data.type;
emit("error", { type: data.type, fatal: true });
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = props.url
video.play()
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = props.url;
video.play();
} else {
video.src = props.url
video.src = props.url;
}
video.onerror = () => {
error.value = 'networkError'
emit('error', { type: 'networkError', fatal: true })
}
}
watch(() => props.url, (newUrl) => {
if (newUrl) initPlayer()
})
video.onerror = () => {
error.value = "networkError";
emit("error", { type: "networkError", fatal: true });
};
};
watch(
() => props.url,
(newUrl) => {
if (newUrl) initPlayer();
},
);
onMounted(() => {
if (props.url) initPlayer()
})
if (props.url) initPlayer();
});
onUnmounted(() => {
if (hls) {
hls.destroy()
hls = null
hls.destroy();
hls = null;
}
})
});
//
defineExpose({
play: (url) => {
if (url) {
props.url = url
initPlayer()
// props.url = url
initPlayer();
}
}
})
},
});
</script>
<style scoped>
@ -128,7 +124,7 @@ defineExpose({
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.9);
background: rgba(0, 0, 0, 0.9);
color: #fff;
gap: 10px;
}

View File

@ -0,0 +1,94 @@
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

@ -0,0 +1,151 @@
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

@ -0,0 +1,47 @@
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

@ -0,0 +1,19 @@
import { onBeforeUnmount } from "vue";
export const useEvent = (eventName, callback) => {
window.addEventListener(eventName, callback);
onBeforeUnmount(() => {
window.removeEventListener(eventName, callback);
});
};
export const useKeyEvent = (key, callback) => {
const handler = (e) => {
console.log("🚀 ~ handler ~ e:", e.key);
if (e.key === key) {
callback(e);
}
};
useEvent("keyup", handler);
};

View File

@ -0,0 +1,76 @@
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

@ -0,0 +1,82 @@
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

@ -0,0 +1,84 @@
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

@ -0,0 +1,42 @@
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

@ -0,0 +1,85 @@
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,33 +1,20 @@
import { ref, computed } from 'vue';
// 防抖工具函数
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
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 debouncedHideBottomPanel = debounce(() => {
bottomPanelVisible.value = false;
}, 3000);
// 当前选中的分组/频道/日期/节目
const selectedGroup = ref('');
const selectedGroup = ref("");
const selectedChannel = ref(null);
const selectedDate = ref('');
const selectedDate = ref("");
const selectedProgram = ref(null);
export function useUI() {
const platform = import.meta.env.VITE_PLATFORM || 'web';
const isTV = platform === 'tv';
const platform = import.meta.env.VITE_PLATFORM || "web";
const isTV = platform === "tv";
// 显示左侧面板
const showLeftPanel = () => {
@ -54,18 +41,13 @@ export function useUI() {
// 显示底部栏(启动防抖隐藏)
const showBottomPanel = () => {
bottomPanelVisible.value = true;
debouncedHideBottomPanel();
hideBottomPanel();
};
// 隐藏底部栏
const hideBottomPanel = () => {
const hideBottomPanel = debounce(() => {
bottomPanelVisible.value = false;
};
// 底部栏交互(重置防抖)
const onBottomInteraction = () => {
debouncedHideBottomPanel();
};
}, 3000);
// 设置选中项
const setSelectedGroup = (group) => {
@ -87,11 +69,11 @@ export function useUI() {
// TV 导航:切换栏
const moveColumn = (direction) => {
if (!leftPanelVisible.value) return;
const maxIndex = 3; // 0-3 四栏
if (direction === 'right') {
if (direction === "right") {
activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex);
} else if (direction === 'left') {
} else if (direction === "left") {
activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0);
}
};
@ -107,18 +89,17 @@ export function useUI() {
selectedChannel,
selectedDate,
selectedProgram,
// 计算属性
isTV,
currentActiveColumn,
// 方法
showLeftPanel,
hideLeftPanel,
toggleLeftPanel,
showBottomPanel,
hideBottomPanel,
onBottomInteraction,
setSelectedGroup,
setSelectedChannel,
setSelectedDate,
@ -126,12 +107,3 @@ export function useUI() {
moveColumn,
};
}
// 创建单例
let uiInstance = null;
export function useUISingleton() {
if (!uiInstance) {
uiInstance = useUI();
}
return uiInstance;
}

View File

@ -1,7 +1,5 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

View File

@ -1,118 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 收藏 & 历史记录 状态管理
export const useStore = defineStore('iptv', () => {
// ============ 收藏 ============
const favorites = ref(new Set())
const loadFavorites = () => {
try {
const saved = localStorage.getItem('iptv_favorites')
if (saved) {
favorites.value = new Set(JSON.parse(saved))
}
} catch (e) {
console.error('加载收藏失败:', e)
}
}
const saveFavorites = () => {
localStorage.setItem('iptv_favorites', JSON.stringify([...favorites.value]))
}
const toggleFavorite = (channelId) => {
if (favorites.value.has(channelId)) {
favorites.value.delete(channelId)
} else {
favorites.value.add(channelId)
}
saveFavorites()
}
const isFavorite = (channelId) => favorites.value.has(channelId)
// ============ 播放历史 ============
const history = ref([])
const MAX_HISTORY = 10
const loadHistory = () => {
try {
const saved = localStorage.getItem('iptv_history')
if (saved) {
history.value = JSON.parse(saved)
}
} catch (e) {
console.error('加载历史失败:', e)
}
}
const saveHistory = () => {
localStorage.setItem('iptv_history', JSON.stringify(history.value.slice(0, MAX_HISTORY)))
}
const addToHistory = (channel) => {
// 移除重复项
history.value = history.value.filter(h => h.id !== channel.id)
// 添加到开头
history.value.unshift({
...channel,
playedAt: Date.now()
})
saveHistory()
}
const clearHistory = () => {
history.value = []
saveHistory()
}
// ============ 设置 ============
const settings = ref({
autoPlay: true,
defaultVolume: 0.8,
showEpg: true,
theme: 'dark',
checkTimeout: 2000, // 检测超时时间(ms)
checkConcurrency: 5 // 并发数
})
const loadSettings = () => {
try {
const saved = localStorage.getItem('iptv_settings')
if (saved) {
settings.value = { ...settings.value, ...JSON.parse(saved) }
}
} catch (e) {
console.error('加载设置失败:', e)
}
}
const saveSettings = () => {
localStorage.setItem('iptv_settings', JSON.stringify(settings.value))
}
const updateSetting = (key, value) => {
settings.value[key] = value
saveSettings()
}
// 初始化
loadFavorites()
loadHistory()
loadSettings()
return {
// 收藏
favorites,
toggleFavorite,
isFavorite,
// 历史
history,
addToHistory,
clearHistory,
// 设置
settings,
updateSetting
}
})

8
ui/src/utils/common.js Normal file
View File

@ -0,0 +1,8 @@
// 防抖工具函数
export const debounce = (fn, delay) => {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
};