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>
|
<template>
|
||||||
<Transition name="slide-up">
|
<Transition name="slide-up">
|
||||||
<div
|
<div
|
||||||
v-show="visible"
|
v-show="bottomPanelVisible"
|
||||||
class="bottom-panel"
|
class="bottom-panel"
|
||||||
@mouseenter="onInteraction"
|
@mouseenter="showBottomPanel"
|
||||||
@mousemove="onInteraction"
|
@mousemove="showBottomPanel"
|
||||||
>
|
>
|
||||||
<!-- 左侧:频道信息 -->
|
<!-- 左侧:频道信息 -->
|
||||||
<div class="panel-left">
|
<div class="panel-left">
|
||||||
@ -33,7 +33,7 @@
|
|||||||
class="action-btn"
|
class="action-btn"
|
||||||
:class="{ active: isFavorite }"
|
:class="{ active: isFavorite }"
|
||||||
@click="handleFavorite"
|
@click="handleFavorite"
|
||||||
@mouseenter="onInteraction"
|
@mouseenter="showBottomPanel"
|
||||||
>
|
>
|
||||||
<span class="icon">★</span>
|
<span class="icon">★</span>
|
||||||
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span>
|
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span>
|
||||||
@ -42,7 +42,7 @@
|
|||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
@click="handleSwitchSource"
|
@click="handleSwitchSource"
|
||||||
@mouseenter="onInteraction"
|
@mouseenter="showBottomPanel"
|
||||||
>
|
>
|
||||||
<span class="icon">↻</span>
|
<span class="icon">↻</span>
|
||||||
<span>切换线路</span>
|
<span>切换线路</span>
|
||||||
@ -51,7 +51,7 @@
|
|||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
@click="handleSettings"
|
@click="handleSettings"
|
||||||
@mouseenter="onInteraction"
|
@mouseenter="showBottomPanel"
|
||||||
>
|
>
|
||||||
<span class="icon">⚙</span>
|
<span class="icon">⚙</span>
|
||||||
<span>设置</span>
|
<span>设置</span>
|
||||||
@ -63,10 +63,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
channel: {
|
channel: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null
|
||||||
@ -97,22 +93,20 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['favorite', 'switch-source', 'settings', 'interaction']);
|
const emit = defineEmits(['favorite', 'switch-source', 'settings']);
|
||||||
|
|
||||||
|
import { useUI } from "../../composables/useUI.js";
|
||||||
|
const { bottomPanelVisible, showBottomPanel } = useUI();
|
||||||
|
|
||||||
// 获取频道 LOGO
|
// 获取频道 LOGO
|
||||||
function getChannelLogo(name) {
|
function getChannelLogo(name) {
|
||||||
return name ? name.slice(0, 2) : '--';
|
return name ? name.slice(0, 2) : '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 交互时触发防抖重置
|
|
||||||
function onInteraction() {
|
|
||||||
emit('interaction');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 收藏
|
// 收藏
|
||||||
function handleFavorite() {
|
function handleFavorite() {
|
||||||
emit('favorite');
|
emit('favorite');
|
||||||
onInteraction();
|
showBottomPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换线路
|
// 切换线路
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<Transition name="slide-left">
|
<Transition name="slide-left">
|
||||||
<div
|
<div
|
||||||
v-show="visible"
|
v-show="leftPanelVisible"
|
||||||
class="left-panel"
|
class="left-panel"
|
||||||
:class="{ 'tv-mode': isTV }"
|
:class="{ 'tv-mode': isTV }"
|
||||||
>
|
>
|
||||||
<!-- 第一栏:分组列表 -->
|
<!-- 第一栏:分组列表 -->
|
||||||
|
<div class="column column-1" :class="{ active: activeColumn === 0 }">
|
||||||
|
<div class="group-list" :class="{ 'is-active': activeColumn === 0 }">
|
||||||
|
<!-- 置顶分组 -->
|
||||||
|
<div class="group-section pinned">
|
||||||
<div
|
<div
|
||||||
class="column column-1"
|
v-for="group in pinnedGroups"
|
||||||
:class="{ active: activeColumn === 0 }"
|
:key="group.id"
|
||||||
|
class="group-item"
|
||||||
|
:class="{ active: selectedGroup === group.id }"
|
||||||
|
@click="onGroupSelect(group.id)"
|
||||||
>
|
>
|
||||||
<GroupList
|
<span class="group-icon">{{ group.icon }}</span>
|
||||||
v-model="selectedGroup"
|
<div class="group-info">
|
||||||
:groups="groups"
|
<span class="group-name">{{ group.name }}</span>
|
||||||
:is-active="activeColumn === 0"
|
<span class="group-count">{{ group.count }}个</span>
|
||||||
@select="onGroupSelect"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- 普通分组 -->
|
||||||
|
<div class="group-section normal">
|
||||||
|
<div
|
||||||
|
v-for="group in normalGroups"
|
||||||
|
:key="group.id"
|
||||||
|
class="group-item"
|
||||||
|
:class="{ active: selectedGroup === group.id }"
|
||||||
|
@click="onGroupSelect(group.id)"
|
||||||
|
>
|
||||||
|
<span class="group-icon">{{ group.icon }}</span>
|
||||||
|
<div class="group-info">
|
||||||
|
<span class="group-name">{{ group.name }}</span>
|
||||||
|
<span class="group-count">{{ group.count }}个</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第二栏:频道列表 -->
|
<!-- 第二栏:频道列表 -->
|
||||||
|
<div class="column column-2" :class="{ active: activeColumn === 1 }">
|
||||||
|
<div class="channel-list" :class="{ 'is-active': activeColumn === 1 }">
|
||||||
<div
|
<div
|
||||||
class="column column-2"
|
v-for="channel in filteredChannels"
|
||||||
:class="{ active: activeColumn === 1 }"
|
:key="channel.id"
|
||||||
|
class="channel-item"
|
||||||
|
:class="{ active: selectedChannel?.id === channel.id }"
|
||||||
|
@click="onChannelSelect(channel)"
|
||||||
>
|
>
|
||||||
<ChannelList
|
<div class="channel-logo">
|
||||||
v-model="selectedChannel"
|
{{ getChannelLogo(channel.name) }}
|
||||||
:channels="filteredChannels"
|
</div>
|
||||||
:is-active="activeColumn === 1"
|
<div class="channel-info">
|
||||||
:favorites="favorites"
|
<div class="channel-name">{{ channel.name }}</div>
|
||||||
:validity-map="validityMap"
|
<div class="channel-meta">
|
||||||
@select="onChannelSelect"
|
<span class="source-count">
|
||||||
/>
|
{{ getValidCount(channel) }}/{{
|
||||||
|
channel.urls?.length || 0
|
||||||
|
}}线路
|
||||||
|
</span>
|
||||||
|
<span v-if="isFavorite(channel.id)" class="favorite-icon"
|
||||||
|
>★</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第三栏:日期列表 -->
|
<!-- 第三栏:日期列表 -->
|
||||||
|
<div class="column column-3" :class="{ active: activeColumn === 2 }">
|
||||||
|
<div class="date-list" :class="{ 'is-active': activeColumn === 2 }">
|
||||||
<div
|
<div
|
||||||
class="column column-3"
|
v-for="date in dates"
|
||||||
:class="{ active: activeColumn === 2 }"
|
:key="date.value"
|
||||||
|
class="date-item"
|
||||||
|
:class="{ active: selectedDate === date.value }"
|
||||||
|
@click="onDateSelect(date.value)"
|
||||||
>
|
>
|
||||||
<DateList
|
<div class="date-day">{{ date.day }}</div>
|
||||||
v-model="selectedDate"
|
<div class="date-label">{{ date.label }}</div>
|
||||||
:is-active="activeColumn === 2"
|
</div>
|
||||||
@select="onDateSelect"
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第四栏:节目单列表 -->
|
<!-- 第四栏:节目单列表 -->
|
||||||
|
<div class="column column-4" :class="{ active: activeColumn === 3 }">
|
||||||
|
<div class="program-list" :class="{ 'is-active': activeColumn === 3 }">
|
||||||
<div
|
<div
|
||||||
class="column column-4"
|
v-for="program in programs"
|
||||||
:class="{ active: activeColumn === 3 }"
|
:key="program.id"
|
||||||
|
class="program-item"
|
||||||
|
:class="{
|
||||||
|
active: selectedProgram?.id === program.id,
|
||||||
|
current: program.isCurrent,
|
||||||
|
}"
|
||||||
|
@click="onProgramSelect(program)"
|
||||||
>
|
>
|
||||||
<ProgramList
|
<div class="program-time">{{ program.time }}</div>
|
||||||
v-model="selectedProgram"
|
<div class="program-title">
|
||||||
:programs="programs"
|
{{ program.title }}
|
||||||
:is-active="activeColumn === 3"
|
<span v-if="program.isCurrent" class="current-badge">当前</span>
|
||||||
@select="onProgramSelect"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
import { computed, watch } from "vue";
|
||||||
import GroupList from './GroupList.vue';
|
import { useUI } from "../../composables/useUI.js";
|
||||||
import ChannelList from './ChannelList.vue';
|
import { useKeyEvent } from "../../composables/useEvent.js";
|
||||||
import DateList from './DateList.vue';
|
import { useGroups } from "../../composables/useGroups.js";
|
||||||
import ProgramList from './ProgramList.vue';
|
import { useChannelFilter } from "../../composables/useChannelFilter.js";
|
||||||
import { useUI } from '../../composables/useUI.js';
|
import { useDates } from "../../composables/useDates.js";
|
||||||
|
import { usePrograms } from "../../composables/usePrograms.js";
|
||||||
|
import { useFavorites } from "../../composables/useFavorites.js";
|
||||||
|
import { useChannels } from "../../composables/useChannels.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
groups: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
currentChannel: {
|
currentChannel: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null,
|
||||||
},
|
|
||||||
favorites: {
|
|
||||||
type: Set,
|
|
||||||
default: () => new Set()
|
|
||||||
},
|
},
|
||||||
validityMap: {
|
validityMap: {
|
||||||
type: Map,
|
type: Map,
|
||||||
default: () => new Map()
|
default: () => new Map(),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'play', 'lookback']);
|
const emit = defineEmits(["play"]);
|
||||||
|
|
||||||
const { isTV, currentActiveColumn, moveColumn } = useUI();
|
const { isTV, leftPanelVisible, hideLeftPanel, currentActiveColumn } = useUI();
|
||||||
const activeColumn = computed(() => currentActiveColumn.value);
|
const activeColumn = computed(() => currentActiveColumn.value);
|
||||||
|
|
||||||
// 选中的状态
|
// 从 hooks 获取数据
|
||||||
const selectedGroup = ref('');
|
const { favorites } = useFavorites();
|
||||||
const selectedChannel = ref(null);
|
const { channels, groups } = useChannels();
|
||||||
const selectedDate = ref('');
|
|
||||||
const selectedProgram = ref(null);
|
|
||||||
|
|
||||||
// 过滤后的频道
|
// ===== 分组逻辑 =====
|
||||||
const filteredChannels = computed(() => {
|
const {
|
||||||
if (!selectedGroup.value) return props.channels;
|
selectedGroup,
|
||||||
|
pinnedGroups,
|
||||||
|
normalGroups,
|
||||||
|
selectGroup,
|
||||||
|
setGroups,
|
||||||
|
initSelectedGroup,
|
||||||
|
} = useGroups();
|
||||||
|
|
||||||
// 置顶分组特殊处理
|
// ===== 频道过滤逻辑 =====
|
||||||
if (selectedGroup.value === 'recent') {
|
const {
|
||||||
// TODO: 返回最近播放
|
selectedChannel,
|
||||||
return [];
|
filteredChannels,
|
||||||
}
|
selectChannel,
|
||||||
if (selectedGroup.value === 'favorite') {
|
setChannels,
|
||||||
// TODO: 返回收藏
|
setSelectedGroup: setChannelFilterGroup,
|
||||||
return props.channels.filter(c => props.favorites.has(c.id));
|
setFavorites,
|
||||||
}
|
setValidityMap,
|
||||||
|
getFirstChannel,
|
||||||
|
getChannelLogo,
|
||||||
|
isFavorite,
|
||||||
|
getValidCount,
|
||||||
|
} = useChannelFilter();
|
||||||
|
|
||||||
return props.channels.filter(c => c.group === selectedGroup.value);
|
// ===== 日期逻辑 =====
|
||||||
|
const { selectedDate, dates, selectDate, initToday } = useDates();
|
||||||
|
|
||||||
|
// ===== 节目单逻辑 =====
|
||||||
|
const { selectedProgram, programs, selectProgram, loadProgramsByDate } =
|
||||||
|
usePrograms();
|
||||||
|
|
||||||
|
// 同步 hooks 数据到过滤逻辑
|
||||||
|
watch(groups, (g) => setGroups(g), { immediate: true });
|
||||||
|
|
||||||
|
watch(channels, (c) => setChannels(c), { immediate: true });
|
||||||
|
|
||||||
|
// 同步收藏到频道过滤
|
||||||
|
watch(favorites, (favs) => setFavorites(favs), { immediate: true });
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.validityMap,
|
||||||
|
(map) => setValidityMap(map),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同步选中分组到频道过滤
|
||||||
|
watch(selectedGroup, (group) => {
|
||||||
|
setChannelFilterGroup(group);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 节目单数据(TODO: 实际从 EPG 获取)
|
|
||||||
const programs = ref([
|
|
||||||
{ id: 1, time: '08:00', title: '朝闻天下', isCurrent: false },
|
|
||||||
{ id: 2, time: '09:00', title: '今日说法', isCurrent: false },
|
|
||||||
{ id: 3, time: '10:00', title: '电视剧:繁花', isCurrent: true },
|
|
||||||
{ id: 4, time: '12:00', title: '新闻30分', isCurrent: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 监听面板显示,初始化选中状态
|
// 监听面板显示,初始化选中状态
|
||||||
watch(() => props.visible, (val) => {
|
watch(leftPanelVisible, (val) => {
|
||||||
if (val && props.currentChannel) {
|
if (val && props.currentChannel) {
|
||||||
// 默认选中当前播放频道对应的分组/频道/日期/节目
|
initSelectedGroup(props.currentChannel, groups.value[0]);
|
||||||
selectedGroup.value = props.currentChannel.group || props.groups[0] || '';
|
selectChannel(props.currentChannel);
|
||||||
selectedChannel.value = props.currentChannel;
|
initToday();
|
||||||
selectedDate.value = new Date().toISOString().split('T')[0];
|
|
||||||
// TODO: 根据当前时间选中当前节目
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 分组选择
|
// 分组选择
|
||||||
function onGroupSelect(groupId) {
|
function onGroupSelect(groupId) {
|
||||||
|
selectGroup(groupId);
|
||||||
// 第二栏自动切换到该分组第一个频道
|
// 第二栏自动切换到该分组第一个频道
|
||||||
const firstChannel = filteredChannels.value[0];
|
const firstChannel = getFirstChannel();
|
||||||
if (firstChannel) {
|
if (firstChannel) {
|
||||||
selectedChannel.value = firstChannel;
|
selectChannel(firstChannel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 频道选择
|
// 频道选择
|
||||||
function onChannelSelect(channel) {
|
function onChannelSelect(channel) {
|
||||||
// 播放频道
|
selectChannel(channel);
|
||||||
emit('play', channel);
|
emit("play", channel);
|
||||||
// 关闭面板
|
hideLeftPanel();
|
||||||
emit('close');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日期选择
|
// 日期选择
|
||||||
function onDateSelect(dateValue) {
|
function onDateSelect(dateValue) {
|
||||||
// 第四栏自动切换到该日期第一个节目
|
selectDate(dateValue);
|
||||||
// TODO: 加载对应日期的节目单
|
loadProgramsByDate(dateValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 节目选择
|
// 节目选择
|
||||||
function onProgramSelect(program) {
|
function onProgramSelect(program) {
|
||||||
// TODO: 回看功能
|
selectProgram(program);
|
||||||
emit('lookback', program);
|
// TODO: 实现回看逻辑
|
||||||
// 关闭面板
|
hideLeftPanel();
|
||||||
emit('close');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 TV 导航(由父组件传入)
|
useKeyEvent("Escape", hideLeftPanel);
|
||||||
watch(activeColumn, (val) => {
|
|
||||||
// 栏切换时的处理
|
|
||||||
});
|
|
||||||
|
|
||||||
// 导出方法供父组件调用
|
|
||||||
defineExpose({
|
|
||||||
moveColumn,
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -234,4 +291,231 @@ defineExpose({
|
|||||||
.slide-left-leave-to {
|
.slide-left-leave-to {
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 分组列表样式 ===== */
|
||||||
|
.group-list {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item:hover,
|
||||||
|
.group-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 频道列表样式 ===== */
|
||||||
|
.channel-list {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item:hover,
|
||||||
|
.channel-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-logo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-icon {
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 日期列表样式 ===== */
|
||||||
|
.date-list {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-item:hover,
|
||||||
|
.date-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-day {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 节目单列表样式 ===== */
|
||||||
|
.program-list {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-item:hover,
|
||||||
|
.program-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-item.active {
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-item.current {
|
||||||
|
border-left-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-time {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: #fff;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TV 模式焦点样式 */
|
||||||
|
.is-active .group-item:focus,
|
||||||
|
.is-active .channel-item:focus,
|
||||||
|
.is-active .date-item:focus,
|
||||||
|
.is-active .program-item:focus {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="video-player">
|
<div class="video-player">
|
||||||
<video
|
<video ref="videoRef" class="video-element" autoplay playsinline></video>
|
||||||
ref="videoRef"
|
|
||||||
class="video-element"
|
|
||||||
controls
|
|
||||||
autoplay
|
|
||||||
playsinline
|
|
||||||
></video>
|
|
||||||
|
|
||||||
<!-- 错误提示 -->
|
<!-- 错误提示 -->
|
||||||
<div v-if="error" class="error-overlay">
|
<div v-if="error" class="error-overlay">
|
||||||
@ -18,90 +12,92 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||||
import Hls from 'hls.js'
|
import Hls from "hls.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
url: String,
|
url: String,
|
||||||
title: String
|
});
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['error', 'ready'])
|
const emit = defineEmits(["error", "ready"]);
|
||||||
|
|
||||||
const videoRef = ref(null)
|
const videoRef = ref(null);
|
||||||
const error = ref(null)
|
const error = ref(null);
|
||||||
let hls = null
|
let hls = null;
|
||||||
|
|
||||||
const initPlayer = () => {
|
const initPlayer = () => {
|
||||||
if (!props.url) return
|
if (!props.url) return;
|
||||||
|
|
||||||
error.value = null
|
error.value = null;
|
||||||
const video = videoRef.value
|
const video = videoRef.value;
|
||||||
|
|
||||||
// 清理旧的 HLS 实例
|
// 清理旧的 HLS 实例
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy()
|
hls.destroy();
|
||||||
hls = null
|
hls = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHLS = props.url.includes('.m3u8')
|
const isHLS = props.url.includes(".m3u8");
|
||||||
|
|
||||||
if (isHLS && Hls.isSupported()) {
|
if (isHLS && Hls.isSupported()) {
|
||||||
hls = new Hls({
|
hls = new Hls({
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
maxBufferLength: 30,
|
maxBufferLength: 30,
|
||||||
})
|
});
|
||||||
|
|
||||||
hls.loadSource(props.url)
|
hls.loadSource(props.url);
|
||||||
hls.attachMedia(video)
|
hls.attachMedia(video);
|
||||||
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
video.play().catch(e => console.log('播放被阻止:', e))
|
video.play().catch((e) => console.log("播放被阻止:", e));
|
||||||
})
|
});
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
error.value = data.type
|
error.value = data.type;
|
||||||
emit('error', { type: data.type, fatal: true })
|
emit("error", { type: data.type, fatal: true });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
video.src = props.url
|
video.src = props.url;
|
||||||
video.play()
|
video.play();
|
||||||
} else {
|
} else {
|
||||||
video.src = props.url
|
video.src = props.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
video.onerror = () => {
|
video.onerror = () => {
|
||||||
error.value = 'networkError'
|
error.value = "networkError";
|
||||||
emit('error', { type: 'networkError', fatal: true })
|
emit("error", { type: "networkError", fatal: true });
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(() => props.url, (newUrl) => {
|
watch(
|
||||||
if (newUrl) initPlayer()
|
() => props.url,
|
||||||
})
|
(newUrl) => {
|
||||||
|
if (newUrl) initPlayer();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.url) initPlayer()
|
if (props.url) initPlayer();
|
||||||
})
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy()
|
hls.destroy();
|
||||||
hls = null
|
hls = null;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// 暴露方法
|
// 暴露方法
|
||||||
defineExpose({
|
defineExpose({
|
||||||
play: (url) => {
|
play: (url) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
props.url = url
|
// props.url = url
|
||||||
initPlayer()
|
initPlayer();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -128,7 +124,7 @@ defineExpose({
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(0,0,0,0.9);
|
background: rgba(0, 0, 0, 0.9);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user