455 lines
14 KiB
HTML
455 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||
<title>Live Catalog - 树形直播目录</title>
|
||
<!-- HLS.js 用于播放 .m3u8 流 -->
|
||
<script src="/static/js/hls.min.js"></script>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
background: #f0f2f5;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 主布局 */
|
||
.app {
|
||
display: flex;
|
||
height: 100vh;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 左侧树形菜单 */
|
||
.sidebar {
|
||
width: 300px;
|
||
background: #fff;
|
||
border-right: 1px solid #e8e8e8;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
||
overflow: auto;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 16px 20px;
|
||
background: #fafafa;
|
||
border-bottom: 1px solid #e8e8e8;
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
color: #1f2f3d;
|
||
}
|
||
|
||
.tree-container {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px 0;
|
||
}
|
||
|
||
/* 树形结构样式 */
|
||
.tree {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding-left: 0;
|
||
}
|
||
|
||
.tree-node {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.tree-node-content {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 12px 8px 20px;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
font-size: 14px;
|
||
color: #2c3e50;
|
||
border-radius: 4px;
|
||
margin: 2px 8px;
|
||
}
|
||
|
||
.tree-node-content:hover {
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.tree-node-content.active {
|
||
background-color: #e6f7ff;
|
||
color: #1890ff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 展开/折叠图标 */
|
||
.expand-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20px;
|
||
height: 20px;
|
||
margin-right: 8px;
|
||
font-size: 12px;
|
||
color: #8c8c8c;
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.expand-icon.expanded {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.node-name {
|
||
flex: 1;
|
||
}
|
||
|
||
/* 加载中指示器 */
|
||
.loading-icon {
|
||
display: inline-block;
|
||
width: 14px;
|
||
height: 14px;
|
||
margin-right: 8px;
|
||
border: 2px solid #ddd;
|
||
border-top-color: #1890ff;
|
||
border-radius: 50%;
|
||
animation: spin 0.6s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* 子节点容器 */
|
||
.children {
|
||
list-style: none;
|
||
padding-left: 28px;
|
||
margin: 0;
|
||
display: none;
|
||
}
|
||
|
||
.children.show {
|
||
display: block;
|
||
}
|
||
|
||
/* 右侧视频区域 */
|
||
.video-area {
|
||
flex: 1;
|
||
background: #141414;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#video-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #000;
|
||
}
|
||
|
||
video {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
background: black;
|
||
}
|
||
|
||
.placeholder {
|
||
color: #ccc;
|
||
font-size: 16px;
|
||
text-align: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
/* 响应式小屏 */
|
||
@media (max-width: 768px) {
|
||
.sidebar {
|
||
width: 260px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<div class="sidebar">
|
||
<div class="sidebar-header">
|
||
📺 直播目录
|
||
</div>
|
||
<div class="tree-container">
|
||
<ul class="tree" id="tree-root">
|
||
<!-- 根节点会动态渲染到这里 -->
|
||
<li class="tree-node loading-placeholder" style="padding: 12px; text-align: center; color: #999;">加载中...</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="video-area">
|
||
<div id="video-container">
|
||
<div class="placeholder">👈 从左侧选择一个直播源</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// API 基础路径(与后端同源)
|
||
const API_BASE = '/api';
|
||
|
||
// 全局状态
|
||
let currentVideoNode = null; // 当前播放的节点对象
|
||
let hls = null; // HLS 实例
|
||
|
||
// 缓存已加载的子节点数据: { parentId: [childrenNodes] }
|
||
const childrenCache = new Map();
|
||
|
||
// 获取根节点
|
||
async function fetchRoots() {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/roots`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return await res.json();
|
||
} catch (err) {
|
||
console.error('获取根节点失败:', err);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 获取指定节点的子节点
|
||
async function fetchChildren(parentId) {
|
||
// 检查缓存
|
||
if (childrenCache.has(parentId)) {
|
||
return childrenCache.get(parentId);
|
||
}
|
||
try {
|
||
const res = await fetch(`${API_BASE}/children/${parentId}`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const children = await res.json();
|
||
childrenCache.set(parentId, children);
|
||
return children;
|
||
} catch (err) {
|
||
console.error(`获取节点 ${parentId} 的子节点失败:`, err);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 获取节点详情(这里主要用来获取视频流地址,但也可以直接用子节点数据,不过为符合 API 设计,单独调用 stream 接口)
|
||
async function fetchStreamUrl(nodeId) {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/stream/${nodeId}`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
return data.url;
|
||
} catch (err) {
|
||
console.error(`获取节点 ${nodeId} 的视频地址失败:`, err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 播放视频
|
||
async function playVideo(node) {
|
||
if (!node || !node.is_leaf) return;
|
||
|
||
// 显示加载占位
|
||
const container = document.getElementById('video-container');
|
||
container.innerHTML = '<div class="placeholder">📡 正在加载直播流...</div>';
|
||
|
||
// 获取流地址
|
||
const streamUrl = await fetchStreamUrl(node.id);
|
||
if (!streamUrl) {
|
||
container.innerHTML = '<div class="placeholder">❌ 无法获取视频地址,请稍后重试</div>';
|
||
return;
|
||
}
|
||
|
||
// 清理旧播放器
|
||
if (hls) {
|
||
hls.destroy();
|
||
hls = null;
|
||
}
|
||
|
||
// 创建 video 元素
|
||
const video = document.createElement('video');
|
||
video.controls = true;
|
||
video.autoplay = true;
|
||
video.style.width = '100%';
|
||
video.style.height = '100%';
|
||
video.style.objectFit = 'contain';
|
||
container.innerHTML = '';
|
||
container.appendChild(video);
|
||
|
||
// 判断是否 HLS 流
|
||
if (Hls.isSupported() && streamUrl.includes('.m3u8')) {
|
||
hls = new Hls();
|
||
hls.loadSource(streamUrl);
|
||
hls.attachMedia(video);
|
||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||
video.play().catch(e => console.warn('自动播放被阻止:', e));
|
||
});
|
||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||
console.error('HLS 错误:', data);
|
||
container.innerHTML = '<div class="placeholder">⚠️ 直播流播放失败,请检查地址或网络</div>';
|
||
});
|
||
} else if (video.canPlayType('application/vnd.apple.mpegurl') && streamUrl.includes('.m3u8')) {
|
||
// 原生支持 HLS(Safari)
|
||
video.src = streamUrl;
|
||
video.addEventListener('loadedmetadata', () => {
|
||
video.play().catch(e => console.warn('自动播放被阻止:', e));
|
||
});
|
||
} else {
|
||
// 非 HLS 直接播放(如 mp4)
|
||
video.src = streamUrl;
|
||
video.addEventListener('error', () => {
|
||
container.innerHTML = '<div class="placeholder">❌ 视频无法播放,格式可能不支持</div>';
|
||
});
|
||
}
|
||
|
||
currentVideoNode = node;
|
||
}
|
||
|
||
// 渲染树节点(递归方式,但为了动态添加子节点,我们只渲染当前层的 ul)
|
||
// 核心思路:为每个节点生成一个 <li>,内部包含 .tree-node-content 和一个 .children 容器
|
||
// 点击节点时,如果已经有 .children 内容则切换显示;否则加载子节点并填充
|
||
async function renderTree() {
|
||
const rootContainer = document.getElementById('tree-root');
|
||
const roots = await fetchRoots();
|
||
if (!roots.length) {
|
||
rootContainer.innerHTML = '<li class="tree-node" style="padding: 12px; text-align: center; color: #999;">暂无数据</li>';
|
||
return;
|
||
}
|
||
|
||
// 清空并重新构建根节点
|
||
rootContainer.innerHTML = '';
|
||
for (const node of roots) {
|
||
const li = createTreeNodeElement(node);
|
||
rootContainer.appendChild(li);
|
||
}
|
||
}
|
||
|
||
// 创建单个树节点的 DOM 元素(不含子节点,子节点容器为空)
|
||
function createTreeNodeElement(node) {
|
||
const li = document.createElement('li');
|
||
li.className = 'tree-node';
|
||
li.dataset.id = node.id;
|
||
li.dataset.isLeaf = node.is_leaf;
|
||
|
||
// 内容包装器
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.className = 'tree-node-content';
|
||
if (!node.is_leaf) {
|
||
// 非叶子节点:带展开图标
|
||
const iconSpan = document.createElement('span');
|
||
iconSpan.className = 'expand-icon';
|
||
iconSpan.innerHTML = '▶'; // 初始向右,展开时旋转
|
||
iconSpan.style.display = 'inline-block';
|
||
contentDiv.appendChild(iconSpan);
|
||
} else {
|
||
// 叶子节点占位空白
|
||
const placeholder = document.createElement('span');
|
||
placeholder.style.width = '28px';
|
||
placeholder.style.display = 'inline-block';
|
||
contentDiv.appendChild(placeholder);
|
||
}
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.className = 'node-name';
|
||
nameSpan.textContent = node.name;
|
||
contentDiv.appendChild(nameSpan);
|
||
|
||
li.appendChild(contentDiv);
|
||
|
||
// 子节点容器
|
||
const childrenUl = document.createElement('ul');
|
||
childrenUl.className = 'children';
|
||
li.appendChild(childrenUl);
|
||
|
||
// 绑定点击事件(在父容器上委托)
|
||
contentDiv.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
onNodeClick(node, li, childrenUl, contentDiv);
|
||
});
|
||
|
||
return li;
|
||
}
|
||
|
||
// 处理节点点击
|
||
async function onNodeClick(node, li, childrenUl, contentDiv) {
|
||
// 如果是叶子节点:播放视频
|
||
if (node.is_leaf) {
|
||
// 高亮当前选中的节点
|
||
clearActiveHighlight();
|
||
contentDiv.classList.add('active');
|
||
await playVideo(node);
|
||
return;
|
||
}
|
||
|
||
// 非叶子节点:展开/折叠逻辑
|
||
const isExpanded = childrenUl.classList.contains('show');
|
||
if (isExpanded) {
|
||
// 折叠
|
||
childrenUl.classList.remove('show');
|
||
const icon = contentDiv.querySelector('.expand-icon');
|
||
if (icon) icon.classList.remove('expanded');
|
||
} else {
|
||
// 展开:检查是否已加载子节点
|
||
if (childrenUl.children.length === 0) {
|
||
// 显示加载动画
|
||
const loadingSpan = document.createElement('span');
|
||
loadingSpan.className = 'loading-icon';
|
||
loadingSpan.style.marginLeft = '8px';
|
||
contentDiv.appendChild(loadingSpan);
|
||
|
||
const children = await fetchChildren(node.id);
|
||
contentDiv.removeChild(loadingSpan);
|
||
|
||
if (children && children.length) {
|
||
for (const child of children) {
|
||
const childLi = createTreeNodeElement(child);
|
||
childrenUl.appendChild(childLi);
|
||
}
|
||
} else {
|
||
// 无子节点:可显示“暂无子节点”提示
|
||
const emptyMsg = document.createElement('li');
|
||
emptyMsg.textContent = '(暂无下级)';
|
||
emptyMsg.style.padding = '6px 12px';
|
||
emptyMsg.style.color = '#999';
|
||
emptyMsg.style.fontSize = '12px';
|
||
childrenUl.appendChild(emptyMsg);
|
||
}
|
||
}
|
||
// 展开并旋转图标
|
||
childrenUl.classList.add('show');
|
||
const icon = contentDiv.querySelector('.expand-icon');
|
||
if (icon) icon.classList.add('expanded');
|
||
}
|
||
}
|
||
|
||
// 清除所有高亮
|
||
function clearActiveHighlight() {
|
||
document.querySelectorAll('.tree-node-content.active').forEach(el => {
|
||
el.classList.remove('active');
|
||
});
|
||
}
|
||
|
||
// 初始化视频区域(可选:尝试自动播放需用户交互)
|
||
function initVideoArea() {
|
||
// 预留
|
||
}
|
||
|
||
// 启动
|
||
(async function init() {
|
||
await renderTree();
|
||
initVideoArea();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |