feat(ui): 实现完整的四栏布局和播放逻辑

- 新增 composables/useUI.js - UI 状态管理和防抖隐藏
- 新增 composables/useStorage.js - Storage 封装
- 新增 Layout 组件 - LeftPanel(四栏)/BottomPanel
- 新增 Modals 组件 - SourceModal/SettingsModal
- 新增 DebugPanel 组件
- 重写 App.vue - 完整播放控制逻辑
  - 渐进式频道加载
  - HTTP HEAD 测速排序
  - 自动选线/失败重试
  - 收藏/最近播放持久化
- 更新 VideoPlayer - 错误事件通知
- 更新 SettingsModal - 缓存管理功能
- 新增 TODO.md
This commit is contained in:
李岩岩 2026-02-05 18:31:37 +08:00
parent 380f4ab4d6
commit d85823cc8d
15 changed files with 2361 additions and 1090 deletions

3
.gitignore vendored
View File

@ -90,11 +90,8 @@ lerna-debug.log*
# ================================================
# Environment
# ================================================
.env
.env.local
.env.*.local
.env.development
.env.test
# ================================================
# Testing

22
TODO.md Normal file
View File

@ -0,0 +1,22 @@
# TODO 列表
## 高优先级
- [x] 多平台打包配置
- [x] Storage API 抽象层
- [x] UI 布局改造(四栏布局)
- [x] 频道加载与测速排序
- [x] 播放控制(自动选线、失败重试)
- [x] 缓存管理(清除功能)
## 中优先级
- [ ] 最近播放逻辑完善(数据持久化)
- [ ] 收藏功能完善(实时更新收藏状态)
- [ ] EPG 节目单数据获取与解析
## 低优先级(后排)
- [ ] TV 遥控器导航(四栏之间方向键移动焦点)
- [ ] 时移播放(回看功能)
- [ ] 搜索功能Web/Desktop

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
<template>
<div class="debug-panel">
<div class="debug-header">
<span>调试信息</span>
<button @click="$emit('close')">×</button>
</div>
<div class="debug-content">
<p>调试功能开发中...</p>
</div>
</div>
</template>
<style scoped>
.debug-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1a1a1a;
border: 2px solid #ff6b6b;
border-radius: 12px;
padding: 16px;
z-index: 9999;
min-width: 300px;
}
.debug-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.debug-header button {
background: #333;
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,273 @@
<template>
<Transition name="slide-up">
<div
v-show="visible"
class="bottom-panel"
@mouseenter="onInteraction"
@mousemove="onInteraction"
>
<!-- 左侧频道信息 -->
<div class="panel-left">
<div class="channel-logo">
{{ getChannelLogo(channel?.name) }}
</div>
<div class="channel-info">
<div class="channel-name-row">
<span class="name">{{ channel?.name || '未选择频道' }}</span>
<span class="source-tag">线路 {{ currentSourceIndex + 1 }}/{{ channel?.urls?.length || 0 }}</span>
</div>
<div class="program-info">
<span class="live-dot"></span>
<span class="program-title">{{ currentProgram || '精彩节目' }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
<span class="time-label">{{ currentTime }} / {{ totalTime }}</span>
</div>
</div>
</div>
<!-- 右侧操作按钮 -->
<div class="panel-right">
<button
class="action-btn"
:class="{ active: isFavorite }"
@click="handleFavorite"
@mouseenter="onInteraction"
>
<span class="icon"></span>
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span>
</button>
<button
class="action-btn"
@click="handleSwitchSource"
@mouseenter="onInteraction"
>
<span class="icon"></span>
<span>切换线路</span>
</button>
<button
class="action-btn"
@click="handleSettings"
@mouseenter="onInteraction"
>
<span class="icon"></span>
<span>设置</span>
</button>
</div>
</div>
</Transition>
</template>
<script setup>
const props = defineProps({
visible: {
type: Boolean,
default: false
},
channel: {
type: Object,
default: null
},
currentSourceIndex: {
type: Number,
default: 0
},
currentProgram: {
type: String,
default: ''
},
progress: {
type: Number,
default: 0
},
currentTime: {
type: String,
default: '--:--'
},
totalTime: {
type: String,
default: '--:--'
},
isFavorite: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['favorite', 'switch-source', 'settings', 'interaction']);
// LOGO
function getChannelLogo(name) {
return name ? name.slice(0, 2) : '--';
}
//
function onInteraction() {
emit('interaction');
}
//
function handleFavorite() {
emit('favorite');
onInteraction();
}
// 线
function handleSwitchSource() {
emit('switch-source');
}
//
function handleSettings() {
emit('settings');
}
</script>
<style scoped>
.bottom-panel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 80px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 50;
}
/* 左侧信息 */
.panel-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.channel-logo {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
color: rgba(255, 255, 255, 0.8);
flex-shrink: 0;
}
.channel-info {
flex: 1;
min-width: 0;
}
.channel-name-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.channel-name-row .name {
font-size: 16px;
font-weight: 500;
color: #fff;
}
.source-tag {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
.program-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 6px;
}
.live-dot {
color: #ff4444;
font-size: 10px;
}
.progress-bar {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
position: relative;
}
.progress-fill {
height: 100%;
background: #fff;
border-radius: 2px;
transition: width 0.3s;
}
.time-label {
position: absolute;
right: 0;
top: -16px;
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
}
/* 右侧按钮 */
.panel-right {
display: flex;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.action-btn.active {
color: #ffd700;
}
.action-btn .icon {
font-size: 14px;
}
/* 滑入动画 */
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,237 @@
<template>
<Transition name="slide-left">
<div
v-show="visible"
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>
<!-- 第二栏频道列表 -->
<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>
<!-- 第三栏日期列表 -->
<div
class="column column-3"
:class="{ active: activeColumn === 2 }"
>
<DateList
v-model="selectedDate"
:is-active="activeColumn === 2"
@select="onDateSelect"
/>
</div>
<!-- 第四栏节目单列表 -->
<div
class="column column-4"
:class="{ active: activeColumn === 3 }"
>
<ProgramList
v-model="selectedProgram"
:programs="programs"
:is-active="activeColumn === 3"
@select="onProgramSelect"
/>
</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';
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()
},
validityMap: {
type: Map,
default: () => new Map()
}
});
const emit = defineEmits(['close', 'play', 'lookback']);
const { isTV, currentActiveColumn, moveColumn } = useUI();
const activeColumn = computed(() => currentActiveColumn.value);
//
const selectedGroup = ref('');
const selectedChannel = ref(null);
const selectedDate = ref('');
const selectedProgram = ref(null);
//
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);
});
// 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) => {
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:
}
});
//
function onGroupSelect(groupId) {
//
const firstChannel = filteredChannels.value[0];
if (firstChannel) {
selectedChannel.value = firstChannel;
}
}
//
function onChannelSelect(channel) {
//
emit('play', channel);
//
emit('close');
}
//
function onDateSelect(dateValue) {
//
// TODO:
}
//
function onProgramSelect(program) {
// TODO:
emit('lookback', program);
//
emit('close');
}
// TV
watch(activeColumn, (val) => {
//
});
//
defineExpose({
moveColumn,
});
</script>
<style scoped>
.left-panel {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 900px;
display: flex;
z-index: 100;
background: transparent;
}
.column {
height: 100%;
overflow: hidden;
}
.column-1 {
width: 140px;
}
.column-2 {
width: 200px;
}
.column-3 {
width: 100px;
}
.column-4 {
flex: 1;
min-width: 200px;
}
/* TV 模式下当前激活栏的高亮 */
.tv-mode .column.active {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
}
/* 滑入动画 */
.slide-left-enter-active,
.slide-left-leave-active {
transition: transform 0.3s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(-100%);
}
</style>

View File

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

View File

@ -0,0 +1,366 @@
<template>
<div class="modal-overlay" @click="$emit('close')">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>设置</h3>
<button class="close-btn" @click="$emit('close')">×</button>
</div>
<div class="settings-list">
<!-- 自动播放 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">自动播放</span>
<span class="setting-desc">切换频道后自动开始播放</span>
</div>
<label class="switch">
<input type="checkbox" v-model="settings.autoPlay" @change="saveSettings" />
<span class="slider"></span>
</label>
</div>
<!-- 首选画质 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">首选画质</span>
<span class="setting-desc">优先选择指定清晰度的线路</span>
</div>
<select v-model="settings.preferredQuality" @change="saveSettings">
<option value="auto">自动</option>
<option value="hd">高清</option>
<option value="sd">标清</option>
</select>
</div>
<!-- 缓存有效期 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">频道列表缓存</span>
<span class="setting-desc">频道数据缓存有效期</span>
</div>
<select v-model="listCacheHours" @change="updateCacheTTL">
<option :value="6">6小时</option>
<option :value="12">12小时</option>
<option :value="24">1</option>
<option :value="72">3</option>
</select>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">线路检测缓存</span>
<span class="setting-desc">线路可用性检测缓存有效期</span>
</div>
<select v-model="validityCacheHours" @change="updateCacheTTL">
<option :value="1">1小时</option>
<option :value="6">6小时</option>
<option :value="12">12小时</option>
<option :value="24">1</option>
</select>
</div>
<div class="divider"></div>
<!-- 缓存管理 -->
<div class="setting-item cache-actions">
<div class="setting-info">
<span class="setting-label">缓存管理</span>
<span class="setting-desc">清除本地缓存数据</span>
</div>
<div class="cache-buttons">
<button class="action-btn" @click="clearChannelCache">
清除频道缓存
</button>
<button class="action-btn" @click="clearValidityCache">
清除检测缓存
</button>
<button class="action-btn danger" @click="clearAllCache">
清除全部缓存
</button>
</div>
</div>
<div class="divider"></div>
<!-- 数据刷新 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">刷新数据</span>
<span class="setting-desc">立即从订阅源重新加载频道</span>
</div>
<button class="action-btn primary" @click="reloadChannels" :disabled="reloading">
{{ reloading ? '刷新中...' : '立即刷新' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useStorage } from '../../composables/useStorage.js';
const emit = defineEmits(['close', 'reload']);
const storage = useStorage();
const settings = ref({
autoPlay: true,
preferredQuality: 'auto',
listCacheTTL: 24 * 60 * 60 * 1000,
validityCacheTTL: 12 * 60 * 60 * 1000,
});
const listCacheHours = ref(24);
const validityCacheHours = ref(12);
const reloading = ref(false);
//
onMounted(async () => {
const prefs = await storage.getPreferences();
settings.value = { ...settings.value, ...prefs };
listCacheHours.value = Math.floor(prefs.listCacheTTL / (60 * 60 * 1000)) || 24;
validityCacheHours.value = Math.floor(prefs.validityCacheTTL / (60 * 60 * 1000)) || 12;
});
//
async function saveSettings() {
await storage.setPreferences(settings.value);
}
//
async function updateCacheTTL() {
settings.value.listCacheTTL = listCacheHours.value * 60 * 60 * 1000;
settings.value.validityCacheTTL = validityCacheHours.value * 60 * 60 * 1000;
await saveSettings();
}
//
async function clearChannelCache() {
if (!confirm('确定要清除频道列表缓存吗?')) return;
await storage.setChannels([]);
await storage.setCacheMeta('channels', null);
alert('频道缓存已清除');
}
//
async function clearValidityCache() {
if (!confirm('确定要清除线路检测缓存吗?')) return;
const all = await storage.getAllValidity();
for (const v of all) {
await storage.remove(`validity_${v.url}`);
}
alert('检测缓存已清除');
}
//
async function clearAllCache() {
if (!confirm('确定要清除全部缓存数据吗?包括收藏和历史记录?')) return;
await storage.clear();
alert('全部缓存已清除');
emit('reload');
emit('close');
}
//
async function reloadChannels() {
reloading.value = true;
emit('reload');
setTimeout(() => {
reloading.value = false;
emit('close');
}, 1000);
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
}
.modal-content {
background: #1a1a1a;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #fff;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
}
.settings-list {
padding: 16px 20px;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.setting-item.cache-actions {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.setting-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-label {
font-size: 14px;
color: #fff;
}
.setting-desc {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 8px 0;
}
/* Switch 开关 */
.switch {
position: relative;
width: 44px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 24px;
transition: 0.3s;
}
.slider:before {
content: '';
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: 0.3s;
}
input:checked + .slider {
background: #00ff88;
}
input:checked + .slider:before {
transform: translateX(20px);
}
/* Select 下拉框 */
select {
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
font-size: 13px;
cursor: pointer;
}
select option {
background: #1a1a1a;
}
/* 按钮 */
.cache-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.action-btn {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.action-btn.primary {
background: #00ff88;
color: #000;
border-color: #00ff88;
}
.action-btn.primary:hover {
background: #00cc6a;
}
.action-btn.danger {
background: rgba(255, 68, 68, 0.2);
border-color: rgba(255, 68, 68, 0.5);
color: #ff4444;
}
.action-btn.danger:hover {
background: rgba(255, 68, 68, 0.3);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,142 @@
<template>
<div class="modal-overlay" @click="$emit('close')">
<div class="modal-content" @click.stop>
<h3>切换线路</h3>
<div class="source-list">
<div
v-for="(url, index) in channel?.urls"
:key="index"
class="source-item"
:class="{
active: currentUrl === url,
online: getValidity(url)?.status === 'online',
offline: getValidity(url)?.status === 'offline'
}"
@click="$emit('switch', { url }, index)"
>
<span class="source-name">线路 {{ index + 1 }}</span>
<span class="source-status" :class="getValidity(url)?.status">
{{ getStatusText(url) }}
</span>
<span class="source-latency" v-if="getValidity(url)?.latency > 0">
{{ getValidity(url).latency }}ms
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
channel: Object,
currentUrl: String,
validityMap: {
type: Map,
default: () => new Map()
}
});
const emit = defineEmits(['switch', 'close']);
function getValidity(url) {
return props.validityMap.get(url);
}
function getStatusText(url) {
const validity = getValidity(url);
if (!validity) return '未检测';
if (validity.status === 'online') return '在线';
if (validity.status === 'offline') return '离线';
return '未知';
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
}
.modal-content {
background: #1a1a1a;
border-radius: 12px;
padding: 20px;
min-width: 400px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.source-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.source-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
cursor: pointer;
border-left: 3px solid transparent;
}
.source-item:hover,
.source-item.active {
background: rgba(255, 255, 255, 0.1);
}
.source-item.active {
border-left-color: #fff;
}
.source-item.online {
border-left-color: #00ff88;
}
.source-item.offline {
border-left-color: #ff4444;
}
.source-name {
font-size: 14px;
color: #fff;
flex: 1;
}
.source-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
}
.source-status.online {
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
}
.source-status.offline {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
}
.source-latency {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
font-family: monospace;
}
</style>

View File

@ -26,6 +26,8 @@ const props = defineProps({
title: String
})
const emit = defineEmits(['error', 'ready'])
const videoRef = ref(null)
const error = ref(null)
let hls = null
@ -60,6 +62,7 @@ const initPlayer = () => {
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
error.value = data.type
emit('error', { type: data.type, fatal: true })
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
@ -71,6 +74,7 @@ const initPlayer = () => {
video.onerror = () => {
error.value = 'networkError'
emit('error', { type: 'networkError', fatal: true })
}
}

View File

@ -0,0 +1,169 @@
import { ref, onMounted } from 'vue';
import createStorage, { Subscription, Channel, SourceValidity, Preferences, PlayHistory, CacheMeta } from '../storage/index.js';
const storage = createStorage();
const isReady = ref(false);
export { Subscription, Channel, SourceValidity, Preferences, PlayHistory, CacheMeta };
export function useStorage() {
onMounted(async () => {
if (storage.init) {
await storage.init();
}
isReady.value = true;
});
// 基础操作
const get = async (key) => {
return await storage.get(key);
};
const set = async (key, value) => {
return await storage.set(key, value);
};
const remove = async (key) => {
return await storage.remove(key);
};
// 频道数据
const getChannels = async () => {
const channels = await storage.getChannels();
return channels.map(c => new Channel(c));
};
const setChannels = async (channels) => {
return await storage.setChannels(channels);
};
const getGroups = async () => {
return await storage.getGroups();
};
// 检查列表缓存是否有效
const isListCacheValid = async () => {
const prefs = await storage.getPreferences();
const ttl = prefs.listCacheTTL || 24 * 60 * 60 * 1000;
return await storage.isCacheValid('channels', ttl);
};
// 线路有效性
const getValidity = async (url) => {
const v = await storage.getValidity(url);
return v ? new SourceValidity(v) : null;
};
const setValidity = async (url, validity) => {
return await storage.setValidity(url, validity);
};
const getAllValidity = async () => {
const list = await storage.getAllValidity();
return list.map(v => new SourceValidity(v));
};
// 检查有效性缓存是否有效
const isValidityCacheValid = async (url) => {
const prefs = await storage.getPreferences();
const ttl = prefs.validityCacheTTL || 12 * 60 * 60 * 1000;
const validity = await storage.getValidity(url);
if (!validity || !validity.checkedAt) return false;
return Date.now() - validity.checkedAt < ttl;
};
// 用户偏好
const getPreferences = async () => {
const prefs = await storage.getPreferences();
return new Preferences(prefs);
};
const setPreferences = async (prefs) => {
return await storage.setPreferences(prefs);
};
// 播放历史
const getHistory = async (limit = 50) => {
const list = await storage.getHistory(limit);
return list.map(h => new PlayHistory(h));
};
const addHistory = async (item) => {
return await storage.addHistory(item);
};
const clearHistory = async () => {
return await storage.clearHistory();
};
// 订阅源
const getSubscriptions = async () => {
const list = await storage.getSubscriptions();
return list.map(s => new Subscription(s));
};
const setSubscriptions = async (subs) => {
return await storage.setSubscriptions(subs);
};
// 缓存元数据
const getCacheMeta = async (key) => {
const meta = await storage.getCacheMeta(key);
return meta ? new CacheMeta(meta) : null;
};
const setCacheMeta = async (key, meta) => {
return await storage.setCacheMeta(key, meta);
};
const isCacheValid = async (key, ttl) => {
return await storage.isCacheValid(key, ttl);
};
// 清空所有数据
const clear = async () => {
return await storage.clear();
};
return {
isReady,
// 基础
get,
set,
remove,
clear,
// 频道
getChannels,
setChannels,
getGroups,
isListCacheValid,
// 有效性
getValidity,
setValidity,
getAllValidity,
isValidityCacheValid,
// 偏好
getPreferences,
setPreferences,
// 历史
getHistory,
addHistory,
clearHistory,
// 订阅
getSubscriptions,
setSubscriptions,
// 元数据
getCacheMeta,
setCacheMeta,
isCacheValid,
};
}
// 创建单例
let storageInstance = null;
export function useStorageSingleton() {
if (!storageInstance) {
storageInstance = useStorage();
}
return storageInstance;
}

137
ui/src/composables/useUI.js Normal file
View File

@ -0,0 +1,137 @@
import { ref, computed } from 'vue';
// 防抖工具函数
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 当前激活的栏索引用于TV导航
const activeColumnIndex = ref(0);
const leftPanelVisible = ref(false);
const bottomPanelVisible = ref(false);
// 防抖隐藏底部栏
const debouncedHideBottomPanel = debounce(() => {
bottomPanelVisible.value = false;
}, 3000);
// 当前选中的分组/频道/日期/节目
const selectedGroup = ref('');
const selectedChannel = ref(null);
const selectedDate = ref('');
const selectedProgram = ref(null);
export function useUI() {
const platform = import.meta.env.VITE_PLATFORM || 'web';
const isTV = platform === 'tv';
// 显示左侧面板
const showLeftPanel = () => {
leftPanelVisible.value = true;
bottomPanelVisible.value = false;
activeColumnIndex.value = 0;
};
// 隐藏左侧面板
const hideLeftPanel = () => {
leftPanelVisible.value = false;
activeColumnIndex.value = 0;
};
// 切换左侧面板
const toggleLeftPanel = () => {
if (leftPanelVisible.value) {
hideLeftPanel();
} else {
showLeftPanel();
}
};
// 显示底部栏(启动防抖隐藏)
const showBottomPanel = () => {
bottomPanelVisible.value = true;
debouncedHideBottomPanel();
};
// 隐藏底部栏
const hideBottomPanel = () => {
bottomPanelVisible.value = false;
};
// 底部栏交互(重置防抖)
const onBottomInteraction = () => {
debouncedHideBottomPanel();
};
// 设置选中项
const setSelectedGroup = (group) => {
selectedGroup.value = group;
};
const setSelectedChannel = (channel) => {
selectedChannel.value = channel;
};
const setSelectedDate = (date) => {
selectedDate.value = date;
};
const setSelectedProgram = (program) => {
selectedProgram.value = program;
};
// TV 导航:切换栏
const moveColumn = (direction) => {
if (!leftPanelVisible.value) return;
const maxIndex = 3; // 0-3 四栏
if (direction === 'right') {
activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex);
} else if (direction === 'left') {
activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0);
}
};
// TV 导航:获取当前激活的栏索引
const currentActiveColumn = computed(() => activeColumnIndex.value);
return {
// 状态
leftPanelVisible,
bottomPanelVisible,
selectedGroup,
selectedChannel,
selectedDate,
selectedProgram,
// 计算属性
isTV,
currentActiveColumn,
// 方法
showLeftPanel,
hideLeftPanel,
toggleLeftPanel,
showBottomPanel,
hideBottomPanel,
onBottomInteraction,
setSelectedGroup,
setSelectedChannel,
setSelectedDate,
setSelectedProgram,
moveColumn,
};
}
// 创建单例
let uiInstance = null;
export function useUISingleton() {
if (!uiInstance) {
uiInstance = useUI();
}
return uiInstance;
}