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"]
}

20
.vscode/settings.json vendored
View File

@ -1,10 +1,5 @@
{ {
"cSpell.words": [ "cSpell.words": ["IPTV", "iptv", "liyanyan", "tauri"],
"IPTV",
"iptv",
"liyanyan",
"tauri"
],
"cSpell.ignorePaths": [ "cSpell.ignorePaths": [
"package-lock.json", "package-lock.json",
"public/api", "public/api",
@ -14,5 +9,16 @@
".git/{index,*refs,*HEAD}", ".git/{index,*refs,*HEAD}",
".vscode", ".vscode",
".vscode-insiders" ".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> <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,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> <template>
<Transition name="slide-up"> <Transition name="slide-up">
<div <div
v-show="visible" v-show="bottomPanelVisible"
class="bottom-panel" class="bottom-panel"
@mouseenter="onInteraction" @mouseenter="showBottomPanel"
@mousemove="onInteraction" @mousemove="showBottomPanel"
> >
<!-- 左侧频道信息 --> <!-- 左侧频道信息 -->
<div class="panel-left"> <div class="panel-left">
@ -33,7 +33,7 @@
class="action-btn" class="action-btn"
:class="{ active: isFavorite }" :class="{ active: isFavorite }"
@click="handleFavorite" @click="handleFavorite"
@mouseenter="onInteraction" @mouseenter="showBottomPanel"
> >
<span class="icon"></span> <span class="icon"></span>
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span> <span>{{ isFavorite ? '已收藏' : '收藏' }}</span>
@ -42,7 +42,7 @@
<button <button
class="action-btn" class="action-btn"
@click="handleSwitchSource" @click="handleSwitchSource"
@mouseenter="onInteraction" @mouseenter="showBottomPanel"
> >
<span class="icon"></span> <span class="icon"></span>
<span>切换线路</span> <span>切换线路</span>
@ -51,7 +51,7 @@
<button <button
class="action-btn" class="action-btn"
@click="handleSettings" @click="handleSettings"
@mouseenter="onInteraction" @mouseenter="showBottomPanel"
> >
<span class="icon"></span> <span class="icon"></span>
<span>设置</span> <span>设置</span>
@ -63,10 +63,6 @@
<script setup> <script setup>
const props = defineProps({ const props = defineProps({
visible: {
type: Boolean,
default: false
},
channel: { channel: {
type: Object, type: Object,
default: null 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 // LOGO
function getChannelLogo(name) { function getChannelLogo(name) {
return name ? name.slice(0, 2) : '--'; return name ? name.slice(0, 2) : '--';
} }
//
function onInteraction() {
emit('interaction');
}
// //
function handleFavorite() { function handleFavorite() {
emit('favorite'); 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> <template>
<div class="debug-panel"> <div class="debug-panel" v-if="showDebug">
<div class="debug-header"> <div class="debug-header">
<span>调试信息</span> <span>调试信息</span>
<button @click="$emit('close')">×</button> <button @click="handleClosePanel">×</button>
</div> </div>
<div class="debug-content"> <div class="debug-content">
<p>调试功能开发中...</p> <p>调试功能开发中...</p>
@ -10,6 +10,31 @@
</div> </div>
</template> </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> <style scoped>
.debug-panel { .debug-panel {
position: fixed; 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> <template>
<Transition name="slide-left"> <Transition name="slide-left">
<div <div
v-show="visible" v-show="leftPanelVisible"
class="left-panel" class="left-panel"
:class="{ 'tv-mode': isTV }" :class="{ 'tv-mode': isTV }"
> >
<!-- 第一栏分组列表 --> <!-- 第一栏分组列表 -->
<div <div class="column column-1" :class="{ active: activeColumn === 0 }">
class="column column-1" <div class="group-list" :class="{ 'is-active': activeColumn === 0 }">
:class="{ active: activeColumn === 0 }" <!-- 置顶分组 -->
> <div class="group-section pinned">
<GroupList <div
v-model="selectedGroup" v-for="group in pinnedGroups"
:groups="groups" :key="group.id"
:is-active="activeColumn === 0" class="group-item"
@select="onGroupSelect" :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>
<!-- 第二栏频道列表 --> <!-- 第二栏频道列表 -->
<div <div class="column column-2" :class="{ active: activeColumn === 1 }">
class="column column-2" <div class="channel-list" :class="{ 'is-active': activeColumn === 1 }">
:class="{ active: activeColumn === 1 }" <div
> v-for="channel in filteredChannels"
<ChannelList :key="channel.id"
v-model="selectedChannel" class="channel-item"
:channels="filteredChannels" :class="{ active: selectedChannel?.id === channel.id }"
:is-active="activeColumn === 1" @click="onChannelSelect(channel)"
:favorites="favorites" >
:validity-map="validityMap" <div class="channel-logo">
@select="onChannelSelect" {{ 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>
<!-- 第三栏日期列表 --> <!-- 第三栏日期列表 -->
<div <div class="column column-3" :class="{ active: activeColumn === 2 }">
class="column column-3" <div class="date-list" :class="{ 'is-active': activeColumn === 2 }">
:class="{ active: activeColumn === 2 }" <div
> v-for="date in dates"
<DateList :key="date.value"
v-model="selectedDate" class="date-item"
:is-active="activeColumn === 2" :class="{ active: selectedDate === date.value }"
@select="onDateSelect" @click="onDateSelect(date.value)"
/> >
<div class="date-day">{{ date.day }}</div>
<div class="date-label">{{ date.label }}</div>
</div>
</div>
</div> </div>
<!-- 第四栏节目单列表 --> <!-- 第四栏节目单列表 -->
<div <div class="column column-4" :class="{ active: activeColumn === 3 }">
class="column column-4" <div class="program-list" :class="{ 'is-active': activeColumn === 3 }">
:class="{ active: activeColumn === 3 }" <div
> v-for="program in programs"
<ProgramList :key="program.id"
v-model="selectedProgram" class="program-item"
:programs="programs" :class="{
:is-active="activeColumn === 3" active: selectedProgram?.id === program.id,
@select="onProgramSelect" 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>
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { computed, watch } from "vue";
import GroupList from './GroupList.vue'; import { useUI } from "../../composables/useUI.js";
import ChannelList from './ChannelList.vue'; import { useKeyEvent } from "../../composables/useEvent.js";
import DateList from './DateList.vue'; import { useGroups } from "../../composables/useGroups.js";
import ProgramList from './ProgramList.vue'; import { useChannelFilter } from "../../composables/useChannelFilter.js";
import { useUI } from '../../composables/useUI.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({ const props = defineProps({
visible: {
type: Boolean,
default: false
},
channels: {
type: Array,
default: () => []
},
groups: {
type: Array,
default: () => []
},
currentChannel: { currentChannel: {
type: Object, type: Object,
default: null default: null,
},
favorites: {
type: Set,
default: () => new Set()
}, },
validityMap: { validityMap: {
type: Map, 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 activeColumn = computed(() => currentActiveColumn.value);
// // hooks
const selectedGroup = ref(''); const { favorites } = useFavorites();
const selectedChannel = ref(null); const { channels, groups } = useChannels();
const selectedDate = ref('');
const selectedProgram = ref(null);
// // ===== =====
const filteredChannels = computed(() => { const {
if (!selectedGroup.value) return props.channels; selectedGroup,
pinnedGroups,
normalGroups,
selectGroup,
setGroups,
initSelectedGroup,
} = useGroups();
// // ===== =====
if (selectedGroup.value === 'recent') { const {
// TODO: selectedChannel,
return []; filteredChannels,
} selectChannel,
if (selectedGroup.value === 'favorite') { setChannels,
// TODO: setSelectedGroup: setChannelFilterGroup,
return props.channels.filter(c => props.favorites.has(c.id)); setFavorites,
} setValidityMap,
getFirstChannel,
getChannelLogo,
isFavorite,
getValidCount,
} = useChannelFilter();
return props.channels.filter(c => c.group === selectedGroup.value); // ===== =====
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) { if (val && props.currentChannel) {
// /// initSelectedGroup(props.currentChannel, groups.value[0]);
selectedGroup.value = props.currentChannel.group || props.groups[0] || ''; selectChannel(props.currentChannel);
selectedChannel.value = props.currentChannel; initToday();
selectedDate.value = new Date().toISOString().split('T')[0];
// TODO:
} }
}); });
// //
function onGroupSelect(groupId) { function onGroupSelect(groupId) {
selectGroup(groupId);
// //
const firstChannel = filteredChannels.value[0]; const firstChannel = getFirstChannel();
if (firstChannel) { if (firstChannel) {
selectedChannel.value = firstChannel; selectChannel(firstChannel);
} }
} }
// //
function onChannelSelect(channel) { function onChannelSelect(channel) {
// selectChannel(channel);
emit('play', channel); emit("play", channel);
// hideLeftPanel();
emit('close');
} }
// //
function onDateSelect(dateValue) { function onDateSelect(dateValue) {
// selectDate(dateValue);
// TODO: loadProgramsByDate(dateValue);
} }
// //
function onProgramSelect(program) { function onProgramSelect(program) {
// TODO: selectProgram(program);
emit('lookback', program); // TODO:
// hideLeftPanel();
emit('close');
} }
// TV useKeyEvent("Escape", hideLeftPanel);
watch(activeColumn, (val) => {
//
});
//
defineExpose({
moveColumn,
});
</script> </script>
<style scoped> <style scoped>
@ -234,4 +291,231 @@ defineExpose({
.slide-left-leave-to { .slide-left-leave-to {
transform: translateX(-100%); 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> </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,12 +1,6 @@
<template> <template>
<div class="video-player"> <div class="video-player">
<video <video ref="videoRef" class="video-element" autoplay playsinline></video>
ref="videoRef"
class="video-element"
controls
autoplay
playsinline
></video>
<!-- 错误提示 --> <!-- 错误提示 -->
<div v-if="error" class="error-overlay"> <div v-if="error" class="error-overlay">
@ -18,90 +12,92 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, onMounted, onUnmounted, watch } from "vue";
import Hls from 'hls.js' import Hls from "hls.js";
const props = defineProps({ const props = defineProps({
url: String, url: String,
title: String });
})
const emit = defineEmits(['error', 'ready']) const emit = defineEmits(["error", "ready"]);
const videoRef = ref(null) const videoRef = ref(null);
const error = ref(null) const error = ref(null);
let hls = null let hls = null;
const initPlayer = () => { const initPlayer = () => {
if (!props.url) return if (!props.url) return;
error.value = null error.value = null;
const video = videoRef.value const video = videoRef.value;
// HLS // HLS
if (hls) { if (hls) {
hls.destroy() hls.destroy();
hls = null hls = null;
} }
const isHLS = props.url.includes('.m3u8') const isHLS = props.url.includes(".m3u8");
if (isHLS && Hls.isSupported()) { if (isHLS && Hls.isSupported()) {
hls = new Hls({ hls = new Hls({
enableWorker: true, enableWorker: true,
maxBufferLength: 30, maxBufferLength: 30,
}) });
hls.loadSource(props.url) hls.loadSource(props.url);
hls.attachMedia(video) hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => { 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) => { hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) { if (data.fatal) {
error.value = data.type error.value = data.type;
emit('error', { type: data.type, fatal: true }) emit("error", { type: data.type, fatal: true });
} }
}) });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = props.url video.src = props.url;
video.play() video.play();
} else { } else {
video.src = props.url video.src = props.url;
} }
video.onerror = () => { video.onerror = () => {
error.value = 'networkError' error.value = "networkError";
emit('error', { type: 'networkError', fatal: true }) emit("error", { type: "networkError", fatal: true });
} };
} };
watch(() => props.url, (newUrl) => { watch(
if (newUrl) initPlayer() () => props.url,
}) (newUrl) => {
if (newUrl) initPlayer();
},
);
onMounted(() => { onMounted(() => {
if (props.url) initPlayer() if (props.url) initPlayer();
}) });
onUnmounted(() => { onUnmounted(() => {
if (hls) { if (hls) {
hls.destroy() hls.destroy();
hls = null hls = null;
} }
}) });
// //
defineExpose({ defineExpose({
play: (url) => { play: (url) => {
if (url) { if (url) {
props.url = url // props.url = url
initPlayer() initPlayer();
} }
} },
}) });
</script> </script>
<style scoped> <style scoped>
@ -128,7 +124,7 @@ defineExpose({
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0,0,0,0.9); background: rgba(0, 0, 0, 0.9);
color: #fff; color: #fff;
gap: 10px; 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'; import { ref, computed } from "vue";
import { debounce } from "../utils/common.js";
// 防抖工具函数
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 当前激活的栏索引用于TV导航 // 当前激活的栏索引用于TV导航
const activeColumnIndex = ref(0); const activeColumnIndex = ref(0);
const leftPanelVisible = ref(false); const leftPanelVisible = ref(false);
const bottomPanelVisible = ref(false); const bottomPanelVisible = ref(false);
// 防抖隐藏底部栏
const debouncedHideBottomPanel = debounce(() => {
bottomPanelVisible.value = false;
}, 3000);
// 当前选中的分组/频道/日期/节目 // 当前选中的分组/频道/日期/节目
const selectedGroup = ref(''); const selectedGroup = ref("");
const selectedChannel = ref(null); const selectedChannel = ref(null);
const selectedDate = ref(''); const selectedDate = ref("");
const selectedProgram = ref(null); const selectedProgram = ref(null);
export function useUI() { export function useUI() {
const platform = import.meta.env.VITE_PLATFORM || 'web'; const platform = import.meta.env.VITE_PLATFORM || "web";
const isTV = platform === 'tv'; const isTV = platform === "tv";
// 显示左侧面板 // 显示左侧面板
const showLeftPanel = () => { const showLeftPanel = () => {
@ -54,18 +41,13 @@ export function useUI() {
// 显示底部栏(启动防抖隐藏) // 显示底部栏(启动防抖隐藏)
const showBottomPanel = () => { const showBottomPanel = () => {
bottomPanelVisible.value = true; bottomPanelVisible.value = true;
debouncedHideBottomPanel(); hideBottomPanel();
}; };
// 隐藏底部栏 // 隐藏底部栏
const hideBottomPanel = () => { const hideBottomPanel = debounce(() => {
bottomPanelVisible.value = false; bottomPanelVisible.value = false;
}; }, 3000);
// 底部栏交互(重置防抖)
const onBottomInteraction = () => {
debouncedHideBottomPanel();
};
// 设置选中项 // 设置选中项
const setSelectedGroup = (group) => { const setSelectedGroup = (group) => {
@ -89,9 +71,9 @@ export function useUI() {
if (!leftPanelVisible.value) return; if (!leftPanelVisible.value) return;
const maxIndex = 3; // 0-3 四栏 const maxIndex = 3; // 0-3 四栏
if (direction === 'right') { if (direction === "right") {
activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex); activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex);
} else if (direction === 'left') { } else if (direction === "left") {
activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0); activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0);
} }
}; };
@ -118,7 +100,6 @@ export function useUI() {
toggleLeftPanel, toggleLeftPanel,
showBottomPanel, showBottomPanel,
hideBottomPanel, hideBottomPanel,
onBottomInteraction,
setSelectedGroup, setSelectedGroup,
setSelectedChannel, setSelectedChannel,
setSelectedDate, setSelectedDate,
@ -126,12 +107,3 @@ export function useUI() {
moveColumn, moveColumn,
}; };
} }
// 创建单例
let uiInstance = null;
export function useUISingleton() {
if (!uiInstance) {
uiInstance = useUI();
}
return uiInstance;
}

View File

@ -1,7 +1,5 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
const app = createApp(App) const app = createApp(App)
app.use(createPinia())
app.mount('#app') 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);
};
};