refactor: 重构组件使用新 hooks
- LeftPanel: 整合子组件,使用 useChannels/useGroups/useFavorites - BottomPanel: 使用 useUI 替代 props - VideoPlayer: 优化播放器逻辑 - DebugPanel: 移动到 Layout 目录 - InputPanel: 新增遥控输入组件 - useUI: 优化 UI 状态管理
This commit is contained in:
parent
bc4434c93d
commit
f7a8e3524c
@ -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();
|
||||
}
|
||||
|
||||
// 切换线路
|
||||
|
||||
68
ui/src/components/Layout/DebugPanel.vue
Normal file
68
ui/src/components/Layout/DebugPanel.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="debug-panel" v-if="showDebug">
|
||||
<div class="debug-header">
|
||||
<span>调试信息</span>
|
||||
<button @click="handleClosePanel">×</button>
|
||||
</div>
|
||||
<div class="debug-content">
|
||||
<p>调试功能开发中...</p>
|
||||
</div>
|
||||
</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;
|
||||
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>
|
||||
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"
|
||||
v-show="leftPanelVisible"
|
||||
class="left-panel"
|
||||
:class="{ 'tv-mode': isTV }"
|
||||
>
|
||||
<!-- 第一栏:分组列表 -->
|
||||
<div class="column column-1" :class="{ active: activeColumn === 0 }">
|
||||
<div class="group-list" :class="{ 'is-active': activeColumn === 0 }">
|
||||
<!-- 置顶分组 -->
|
||||
<div class="group-section pinned">
|
||||
<div
|
||||
class="column column-1"
|
||||
:class="{ active: activeColumn === 0 }"
|
||||
v-for="group in pinnedGroups"
|
||||
:key="group.id"
|
||||
class="group-item"
|
||||
:class="{ active: selectedGroup === group.id }"
|
||||
@click="onGroupSelect(group.id)"
|
||||
>
|
||||
<GroupList
|
||||
v-model="selectedGroup"
|
||||
:groups="groups"
|
||||
:is-active="activeColumn === 0"
|
||||
@select="onGroupSelect"
|
||||
/>
|
||||
<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 }">
|
||||
<div class="channel-list" :class="{ 'is-active': activeColumn === 1 }">
|
||||
<div
|
||||
class="column column-2"
|
||||
:class="{ active: activeColumn === 1 }"
|
||||
v-for="channel in filteredChannels"
|
||||
:key="channel.id"
|
||||
class="channel-item"
|
||||
:class="{ active: selectedChannel?.id === channel.id }"
|
||||
@click="onChannelSelect(channel)"
|
||||
>
|
||||
<ChannelList
|
||||
v-model="selectedChannel"
|
||||
:channels="filteredChannels"
|
||||
:is-active="activeColumn === 1"
|
||||
:favorites="favorites"
|
||||
:validity-map="validityMap"
|
||||
@select="onChannelSelect"
|
||||
/>
|
||||
<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 }">
|
||||
<div class="date-list" :class="{ 'is-active': activeColumn === 2 }">
|
||||
<div
|
||||
class="column column-3"
|
||||
:class="{ active: activeColumn === 2 }"
|
||||
v-for="date in dates"
|
||||
:key="date.value"
|
||||
class="date-item"
|
||||
:class="{ active: selectedDate === date.value }"
|
||||
@click="onDateSelect(date.value)"
|
||||
>
|
||||
<DateList
|
||||
v-model="selectedDate"
|
||||
:is-active="activeColumn === 2"
|
||||
@select="onDateSelect"
|
||||
/>
|
||||
<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 }">
|
||||
<div class="program-list" :class="{ 'is-active': activeColumn === 3 }">
|
||||
<div
|
||||
class="column column-4"
|
||||
:class="{ active: activeColumn === 3 }"
|
||||
v-for="program in programs"
|
||||
:key="program.id"
|
||||
class="program-item"
|
||||
:class="{
|
||||
active: selectedProgram?.id === program.id,
|
||||
current: program.isCurrent,
|
||||
}"
|
||||
@click="onProgramSelect(program)"
|
||||
>
|
||||
<ProgramList
|
||||
v-model="selectedProgram"
|
||||
:programs="programs"
|
||||
:is-active="activeColumn === 3"
|
||||
@select="onProgramSelect"
|
||||
/>
|
||||
<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;
|
||||
// ===== 分组逻辑 =====
|
||||
const {
|
||||
selectedGroup,
|
||||
pinnedGroups,
|
||||
normalGroups,
|
||||
selectGroup,
|
||||
setGroups,
|
||||
initSelectedGroup,
|
||||
} = useGroups();
|
||||
|
||||
// 置顶分组特殊处理
|
||||
if (selectedGroup.value === 'recent') {
|
||||
// TODO: 返回最近播放
|
||||
return [];
|
||||
}
|
||||
if (selectedGroup.value === 'favorite') {
|
||||
// TODO: 返回收藏
|
||||
return props.channels.filter(c => props.favorites.has(c.id));
|
||||
}
|
||||
// ===== 频道过滤逻辑 =====
|
||||
const {
|
||||
selectedChannel,
|
||||
filteredChannels,
|
||||
selectChannel,
|
||||
setChannels,
|
||||
setSelectedGroup: setChannelFilterGroup,
|
||||
setFavorites,
|
||||
setValidityMap,
|
||||
getFirstChannel,
|
||||
getChannelLogo,
|
||||
isFavorite,
|
||||
getValidCount,
|
||||
} = useChannelFilter();
|
||||
|
||||
return props.channels.filter(c => c.group === selectedGroup.value);
|
||||
// ===== 日期逻辑 =====
|
||||
const { selectedDate, dates, selectDate, initToday } = useDates();
|
||||
|
||||
// ===== 节目单逻辑 =====
|
||||
const { selectedProgram, programs, selectProgram, loadProgramsByDate } =
|
||||
usePrograms();
|
||||
|
||||
// 同步 hooks 数据到过滤逻辑
|
||||
watch(groups, (g) => setGroups(g), { immediate: true });
|
||||
|
||||
watch(channels, (c) => setChannels(c), { immediate: true });
|
||||
|
||||
// 同步收藏到频道过滤
|
||||
watch(favorites, (favs) => setFavorites(favs), { immediate: true });
|
||||
|
||||
watch(
|
||||
() => props.validityMap,
|
||||
(map) => setValidityMap(map),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 同步选中分组到频道过滤
|
||||
watch(selectedGroup, (group) => {
|
||||
setChannelFilterGroup(group);
|
||||
});
|
||||
|
||||
// 节目单数据(TODO: 实际从 EPG 获取)
|
||||
const programs = ref([
|
||||
{ id: 1, time: '08:00', title: '朝闻天下', isCurrent: false },
|
||||
{ id: 2, time: '09:00', title: '今日说法', isCurrent: false },
|
||||
{ id: 3, time: '10:00', title: '电视剧:繁花', isCurrent: true },
|
||||
{ id: 4, time: '12:00', title: '新闻30分', isCurrent: false },
|
||||
]);
|
||||
|
||||
// 监听面板显示,初始化选中状态
|
||||
watch(() => props.visible, (val) => {
|
||||
watch(leftPanelVisible, (val) => {
|
||||
if (val && props.currentChannel) {
|
||||
// 默认选中当前播放频道对应的分组/频道/日期/节目
|
||||
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,12 +1,6 @@
|
||||
<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">
|
||||
@ -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
|
||||
if (!props.url) return;
|
||||
|
||||
error.value = null
|
||||
const video = videoRef.value
|
||||
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 })
|
||||
}
|
||||
}
|
||||
error.value = "networkError";
|
||||
emit("error", { type: "networkError", fatal: true });
|
||||
};
|
||||
};
|
||||
|
||||
watch(() => props.url, (newUrl) => {
|
||||
if (newUrl) initPlayer()
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user