Compare commits
4 Commits
d85823cc8d
...
c0428c5d3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0428c5d3d | ||
|
|
f7a8e3524c | ||
|
|
bc4434c93d | ||
|
|
5f8165b236 |
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar", "esbenp.prettier-vscode"]
|
||||
}
|
||||
22
.vscode/settings.json
vendored
22
.vscode/settings.json
vendored
@ -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"
|
||||
}
|
||||
|
||||
403
ui/src/App.vue
403
ui/src/App.vue
@ -1,34 +1,17 @@
|
||||
<template>
|
||||
<div class="iptv-app">
|
||||
<!-- 调试面板 -->
|
||||
<DebugPanel v-if="showDebug" @close="showDebug = false" />
|
||||
<button v-else class="debug-toggle" @click="showDebug = true">🐛</button>
|
||||
|
||||
<!-- 全屏视频播放器 -->
|
||||
<VideoPlayer
|
||||
ref="playerRef"
|
||||
:url="currentUrl"
|
||||
:title="currentChannel?.name"
|
||||
class="video-layer"
|
||||
@error="handlePlayError"
|
||||
/>
|
||||
|
||||
<VideoPlayer ref="playerRef" :url="currentUrl" @error="handlePlayError" />
|
||||
|
||||
<!-- 左侧面板(四栏) -->
|
||||
<LeftPanel
|
||||
:visible="leftPanelVisible"
|
||||
:channels="channels"
|
||||
:groups="groups"
|
||||
:current-channel="currentChannel"
|
||||
:favorites="favorites"
|
||||
:validity-map="validityMap"
|
||||
@close="hideLeftPanel"
|
||||
@play="handlePlay"
|
||||
@lookback="handleLookback"
|
||||
/>
|
||||
|
||||
|
||||
<!-- 底部信息栏 -->
|
||||
<BottomPanel
|
||||
:visible="bottomPanelVisible"
|
||||
:channel="currentChannel"
|
||||
:current-source-index="currentSourceIndex"
|
||||
:current-program="currentProgramTitle"
|
||||
@ -39,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>
|
||||
|
||||
@ -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>
|
||||
@ -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();
|
||||
}
|
||||
|
||||
// 切换线路
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
@ -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>
|
||||
93
ui/src/components/Layout/InputPanel.vue
Normal file
93
ui/src/components/Layout/InputPanel.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
94
ui/src/composables/useChannelFilter.js
Normal file
94
ui/src/composables/useChannelFilter.js
Normal 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,
|
||||
};
|
||||
}
|
||||
151
ui/src/composables/useChannels.js
Normal file
151
ui/src/composables/useChannels.js
Normal 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,
|
||||
};
|
||||
}
|
||||
47
ui/src/composables/useDates.js
Normal file
47
ui/src/composables/useDates.js
Normal 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,
|
||||
};
|
||||
}
|
||||
19
ui/src/composables/useEvent.js
Normal file
19
ui/src/composables/useEvent.js
Normal 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);
|
||||
};
|
||||
76
ui/src/composables/useFavorites.js
Normal file
76
ui/src/composables/useFavorites.js
Normal 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,
|
||||
};
|
||||
}
|
||||
82
ui/src/composables/useGroups.js
Normal file
82
ui/src/composables/useGroups.js
Normal 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,
|
||||
};
|
||||
}
|
||||
84
ui/src/composables/useHistory.js
Normal file
84
ui/src/composables/useHistory.js
Normal 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,
|
||||
};
|
||||
}
|
||||
42
ui/src/composables/usePrograms.js
Normal file
42
ui/src/composables/usePrograms.js
Normal 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,
|
||||
};
|
||||
}
|
||||
85
ui/src/composables/useSettings.js
Normal file
85
ui/src/composables/useSettings.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
8
ui/src/utils/common.js
Normal 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);
|
||||
};
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user