Files
SupervisorAI/live_catalog/index.html
2026-04-02 13:17:30 +08:00

552 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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-controls {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent);
display: flex;
align-items: center;
gap: 16px;
z-index: 10;
}
.video-controls label {
color: #fff;
font-size: 13px;
}
.video-controls select {
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #444;
background: #333;
color: #fff;
font-size: 13px;
cursor: pointer;
}
/* 视频信息栏 */
.video-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
color: #ccc;
font-size: 12px;
z-index: 10;
font-family: monospace;
word-break: break-all;
}
.video-info .info-row {
margin: 4px 0;
}
.video-info .info-label {
color: #888;
margin-right: 8px;
}
.video-info .info-value {
color: #0f0;
}
#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 class="video-controls" id="video-controls" style="display: none;">
<label>码流类型:</label>
<select id="stream-type">
<option value="0">主码流(高清)</option>
<option value="1">子码流(流畅)</option>
</select>
</div>
<div id="video-container">
<div class="placeholder">👈 从左侧选择一个直播源</div>
</div>
<!-- 视频信息栏 -->
<div class="video-info" id="video-info" style="display: none;">
<div class="info-row">
<span class="info-label">摄像头ID</span>
<span class="info-value" id="info-camera-id">-</span>
</div>
<div class="info-row">
<span class="info-label">播放地址:</span>
<span class="info-value" id="info-url">-</span>
</div>
</div>
</div>
</div>
<script>
// API 基础路径(与后端同源)
const API_BASE = '/api';
// 全局状态
let currentVideoNode = null; // 当前播放的节点对象
let hls = null; // HLS 实例
let currentStreamType = 0; // 当前码流类型
// 缓存已加载的子节点数据: { 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 [];
}
}
// 获取视频流地址
async function fetchStreamUrl(nodeId, streamType = 0) {
try {
const res = await fetch(`${API_BASE}/stream/${nodeId}?stream_type=${streamType}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error(`获取节点 ${nodeId} 的视频地址失败:`, err);
return null;
}
}
// 播放视频
async function playVideo(node, streamType = 0) {
if (!node || !node.is_leaf) return;
// 显示加载占位
const container = document.getElementById('video-container');
container.innerHTML = '<div class="placeholder">📡 正在加载直播流...</div>';
// 隐藏信息和控制栏
document.getElementById('video-controls').style.display = 'none';
document.getElementById('video-info').style.display = 'none';
// 获取流地址
const streamData = await fetchStreamUrl(node.id, streamType);
if (!streamData || !streamData.url) {
container.innerHTML = '<div class="placeholder">❌ 无法获取视频地址,请稍后重试</div>';
return;
}
const streamUrl = streamData.url;
// 清理旧播放器
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')) {
// 原生支持 HLSSafari
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>';
});
}
// 更新并显示视频信息
document.getElementById('info-camera-id').textContent = streamData.cameraIndexCode;
document.getElementById('info-url').textContent = streamUrl;
document.getElementById('video-info').style.display = 'block';
// 显示控制栏
document.getElementById('video-controls').style.display = 'flex';
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, currentStreamType);
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() {
// 监听码流类型切换
document.getElementById('stream-type').addEventListener('change', async (e) => {
currentStreamType = parseInt(e.target.value);
// 如果当前有播放的视频,重新加载
if (currentVideoNode) {
await playVideo(currentVideoNode, currentStreamType);
}
});
}
// 启动
(async function init() {
await renderTree();
initVideoArea();
})();
</script>
</body>
</html>