lint / go (push) Canceled after 0s
lint / go_mod (push) Canceled after 0s
lint / conf (push) Canceled after 0s
lint / docslinks (push) Canceled after 0s
lint / docsorder (push) Canceled after 0s
lint / apidocs (push) Canceled after 0s
lint / other (push) Canceled after 0s
test / test_64 (push) Canceled after 0s
test / test_32 (push) Canceled after 0s
test / test_e2e (push) Canceled after 0s
646 lines
23 KiB
JavaScript
646 lines
23 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const API = {
|
|
configs: "/v3/config/paths/list",
|
|
paths: "/v3/paths/list",
|
|
global: "/v3/config/global/get",
|
|
};
|
|
|
|
const state = {
|
|
configs: [],
|
|
runtime: new Map(),
|
|
global: {},
|
|
editingName: null,
|
|
deletingName: null,
|
|
previewName: null,
|
|
previewProtocol: "webrtc",
|
|
loading: false,
|
|
toastTimer: null,
|
|
};
|
|
|
|
const elements = {};
|
|
|
|
class APIError extends Error {
|
|
constructor(status, message) {
|
|
super(message);
|
|
this.name = "APIError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
function byID(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function create(tag, options = {}) {
|
|
const node = document.createElement(tag);
|
|
if (options.className) node.className = options.className;
|
|
if (options.text !== undefined) node.textContent = String(options.text);
|
|
if (options.title) node.title = options.title;
|
|
return node;
|
|
}
|
|
|
|
function redactSensitiveText(value) {
|
|
return String(value || "").replace(
|
|
/(rtsps?:\/\/)([^\s/:@]+):([^\s/@]+)@/gi,
|
|
"$1$2:••••@",
|
|
);
|
|
}
|
|
|
|
async function apiFetch(url, options = {}) {
|
|
const headers = new Headers(options.headers || {});
|
|
if (options.body !== undefined) headers.set("Content-Type", "application/json");
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(url, {
|
|
...options,
|
|
headers,
|
|
cache: "no-store",
|
|
credentials: "same-origin",
|
|
});
|
|
} catch (error) {
|
|
throw new APIError(0, "无法连接 MediaMTX Control API。请检查服务和网络连接。");
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let detail = "";
|
|
try {
|
|
const payload = await response.json();
|
|
detail = typeof payload.error === "string" ? redactSensitiveText(payload.error) : "";
|
|
} catch (_) {
|
|
detail = "";
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
throw new APIError(401, "认证失败。请使用具有 action: api 权限的凭据重新访问此页面。");
|
|
}
|
|
if (response.status === 403) {
|
|
throw new APIError(403, "当前凭据没有修改路径的 action: api 权限。");
|
|
}
|
|
|
|
throw new APIError(response.status, detail || `请求失败(HTTP ${response.status})。`);
|
|
}
|
|
|
|
if (response.status === 204) return null;
|
|
const contentType = response.headers.get("Content-Type") || "";
|
|
return contentType.includes("json") ? response.json() : null;
|
|
}
|
|
|
|
function maskSource(raw) {
|
|
try {
|
|
const parsed = new URL(raw);
|
|
const username = parsed.username ? decodeURIComponent(parsed.username) : "";
|
|
const auth = username
|
|
? `${username}${parsed.password ? ":••••" : ""}@`
|
|
: "";
|
|
return `${parsed.protocol}//${auth}${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
} catch (_) {
|
|
return "RTSP 地址不可解析";
|
|
}
|
|
}
|
|
|
|
function scrubConfig(item) {
|
|
return {
|
|
name: String(item.name || ""),
|
|
sourceMasked: maskSource(String(item.source || "")),
|
|
transport: ["automatic", "tcp", "udp"].includes(item.rtspTransport)
|
|
? item.rtspTransport
|
|
: "automatic",
|
|
sourceOnDemand: Boolean(item.sourceOnDemand),
|
|
};
|
|
}
|
|
|
|
function isRTSPSource(item) {
|
|
const source = String(item.source || "").toLowerCase();
|
|
return source.startsWith("rtsp://") || source.startsWith("rtsps://");
|
|
}
|
|
|
|
function getPathState(config) {
|
|
const runtime = state.runtime.get(config.name);
|
|
if (runtime?.available) {
|
|
return { key: "online", label: "在线", className: "status-online" };
|
|
}
|
|
if (config.sourceOnDemand) {
|
|
return { key: "idle", label: "待请求", className: "status-idle" };
|
|
}
|
|
return { key: "offline", label: "离线", className: "status-offline" };
|
|
}
|
|
|
|
function readerCount(name) {
|
|
const readers = state.runtime.get(name)?.readers;
|
|
return Array.isArray(readers) ? readers.length : 0;
|
|
}
|
|
|
|
function setAPIStatus(kind, text) {
|
|
elements.apiStatus.className = `status-pill status-${kind}`;
|
|
elements.apiStatus.textContent = text;
|
|
}
|
|
|
|
function showPageError(error) {
|
|
elements.pageAlert.hidden = false;
|
|
elements.pageAlertTitle.textContent = error.status === 403 ? "没有操作权限" : "无法加载路径";
|
|
elements.pageAlertMessage.textContent = error.message;
|
|
setAPIStatus("error", error.status === 403 ? "API 权限不足" : "API 连接失败");
|
|
}
|
|
|
|
function hidePageError() {
|
|
elements.pageAlert.hidden = true;
|
|
elements.pageAlertTitle.textContent = "";
|
|
elements.pageAlertMessage.textContent = "";
|
|
}
|
|
|
|
function setLoading(loading) {
|
|
state.loading = loading;
|
|
elements.loadingState.hidden = !loading;
|
|
elements.refreshButton.disabled = loading;
|
|
if (loading) {
|
|
elements.pathsContent.hidden = true;
|
|
elements.emptyState.hidden = true;
|
|
setAPIStatus("loading", "正在连接 API");
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
if (state.loading) return;
|
|
setLoading(true);
|
|
hidePageError();
|
|
|
|
try {
|
|
const [configData, pathData, globalData] = await Promise.all([
|
|
apiFetch(API.configs),
|
|
apiFetch(API.paths),
|
|
apiFetch(API.global).catch(() => ({})),
|
|
]);
|
|
|
|
state.configs = (configData.items || [])
|
|
.filter(isRTSPSource)
|
|
.map(scrubConfig);
|
|
state.runtime = new Map((pathData.items || []).map((item) => [item.name, item]));
|
|
state.global = globalData || {};
|
|
|
|
setAPIStatus("ok", "API 已连接");
|
|
render();
|
|
} catch (error) {
|
|
state.configs = [];
|
|
state.runtime = new Map();
|
|
elements.pathsContent.hidden = true;
|
|
elements.emptyState.hidden = true;
|
|
showPageError(error instanceof APIError ? error : new APIError(0, String(error)));
|
|
updateStats([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function filteredConfigs() {
|
|
const query = elements.searchInput.value.trim().toLocaleLowerCase();
|
|
const filter = elements.statusFilter.value;
|
|
return state.configs.filter((config) => {
|
|
const matchesQuery = !query || config.name.toLocaleLowerCase().includes(query)
|
|
|| config.sourceMasked.toLocaleLowerCase().includes(query);
|
|
const matchesStatus = filter === "all" || getPathState(config).key === filter;
|
|
return matchesQuery && matchesStatus;
|
|
});
|
|
}
|
|
|
|
function updateStats(configs) {
|
|
const counts = { online: 0, offline: 0, idle: 0 };
|
|
for (const config of configs) counts[getPathState(config).key] += 1;
|
|
elements.statTotal.textContent = String(configs.length);
|
|
elements.statOnline.textContent = String(counts.online);
|
|
elements.statOffline.textContent = String(counts.offline);
|
|
elements.statIdle.textContent = String(counts.idle);
|
|
}
|
|
|
|
function statusNode(config) {
|
|
const status = getPathState(config);
|
|
return create("span", { className: `path-status ${status.className}`, text: status.label });
|
|
}
|
|
|
|
function actionButton(label, className, handler) {
|
|
const button = create("button", { className: `button ${className}`, text: label });
|
|
button.type = "button";
|
|
button.addEventListener("click", handler);
|
|
return button;
|
|
}
|
|
|
|
function actionsNode(config) {
|
|
const actions = create("div", { className: "row-actions" });
|
|
actions.append(
|
|
actionButton("预览", "button-primary", () => openPreview(config.name)),
|
|
actionButton("编辑", "button-secondary", () => openPathDialog(config)),
|
|
actionButton("删除", "button-secondary", () => openDeleteDialog(config.name)),
|
|
);
|
|
return actions;
|
|
}
|
|
|
|
function renderTable(configs) {
|
|
const rows = document.createDocumentFragment();
|
|
for (const config of configs) {
|
|
const row = create("tr");
|
|
const nameCell = create("td");
|
|
nameCell.append(create("span", { className: "path-name", text: config.name }));
|
|
const statusCell = create("td");
|
|
statusCell.append(statusNode(config));
|
|
const sourceCell = create("td");
|
|
sourceCell.append(create("span", {
|
|
className: "source-address",
|
|
text: config.sourceMasked,
|
|
title: config.sourceMasked,
|
|
}));
|
|
const transportCell = create("td", { text: transportLabel(config.transport) });
|
|
const readersCell = create("td", { className: "readers", text: readerCount(config.name) });
|
|
const actionsCell = create("td");
|
|
actionsCell.append(actionsNode(config));
|
|
row.append(nameCell, statusCell, sourceCell, transportCell, readersCell, actionsCell);
|
|
rows.append(row);
|
|
}
|
|
elements.tableBody.replaceChildren(rows);
|
|
}
|
|
|
|
function renderCards(configs) {
|
|
const cards = document.createDocumentFragment();
|
|
for (const config of configs) {
|
|
const card = create("article", { className: "path-card" });
|
|
const heading = create("div", { className: "path-card-heading" });
|
|
heading.append(create("strong", { className: "path-name", text: config.name }), statusNode(config));
|
|
card.append(
|
|
heading,
|
|
create("span", { className: "source-address", text: config.sourceMasked }),
|
|
create("p", {
|
|
className: "path-card-meta",
|
|
text: `${transportLabel(config.transport)} · ${readerCount(config.name)} 个读取者`,
|
|
}),
|
|
actionsNode(config),
|
|
);
|
|
cards.append(card);
|
|
}
|
|
elements.cardList.replaceChildren(cards);
|
|
}
|
|
|
|
function render() {
|
|
updateStats(state.configs);
|
|
const configs = filteredConfigs();
|
|
const hasFilters = Boolean(elements.searchInput.value.trim()) || elements.statusFilter.value !== "all";
|
|
|
|
if (configs.length === 0) {
|
|
elements.pathsContent.hidden = true;
|
|
elements.emptyState.hidden = false;
|
|
elements.emptyTitle.textContent = hasFilters ? "没有匹配的路径" : "还没有 RTSP 路径";
|
|
elements.emptyMessage.textContent = hasFilters
|
|
? "请调整搜索词或状态筛选。"
|
|
: "添加第一个摄像头或 RTSP 服务地址,之后可在列表中预览与管理。";
|
|
elements.emptyAddButton.hidden = hasFilters;
|
|
return;
|
|
}
|
|
|
|
elements.emptyState.hidden = true;
|
|
elements.pathsContent.hidden = false;
|
|
renderTable(configs);
|
|
renderCards(configs);
|
|
}
|
|
|
|
function transportLabel(value) {
|
|
return { automatic: "自动", tcp: "TCP", udp: "UDP" }[value] || "自动";
|
|
}
|
|
|
|
function clearFieldError(input, errorElement) {
|
|
input.removeAttribute("aria-invalid");
|
|
errorElement.textContent = "";
|
|
}
|
|
|
|
function setFieldError(input, errorElement, message) {
|
|
input.setAttribute("aria-invalid", "true");
|
|
errorElement.textContent = message;
|
|
}
|
|
|
|
function validatePathName(name) {
|
|
if (!name) return "请输入路径名称。";
|
|
if (name === "all" || name === "all_others") return "该名称为 MediaMTX 保留名称。";
|
|
if (name.startsWith("/") || name.endsWith("/") || name.includes("//")) {
|
|
return "路径名称不能以斜杠开头或结尾,也不能包含空路径段。";
|
|
}
|
|
if (name.split("/").some((part) => part === "." || part === "..")) {
|
|
return "路径名称不能包含 . 或 .. 路径段。";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function validateSource(value, required) {
|
|
if (!value) return required ? "请输入 RTSP 源地址。" : "";
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (!["rtsp:", "rtsps:"].includes(parsed.protocol) || !parsed.hostname) {
|
|
return "请输入以 rtsp:// 或 rtsps:// 开头的有效地址。";
|
|
}
|
|
return "";
|
|
} catch (_) {
|
|
return "请输入有效的 RTSP 地址。";
|
|
}
|
|
}
|
|
|
|
function openPathDialog(config = null) {
|
|
state.editingName = config?.name || null;
|
|
const editing = Boolean(config);
|
|
elements.pathDialogTitle.textContent = editing ? "编辑 RTSP 源" : "添加 RTSP 源";
|
|
elements.pathSubmit.textContent = editing ? "保存修改" : "添加路径";
|
|
elements.pathName.value = config?.name || "";
|
|
elements.pathName.readOnly = editing;
|
|
elements.sourceURL.value = "";
|
|
elements.sourceURL.type = "password";
|
|
elements.sourceToggle.textContent = "显示";
|
|
elements.sourceToggle.setAttribute("aria-pressed", "false");
|
|
elements.transport.value = config?.transport || "automatic";
|
|
elements.sourceOnDemand.checked = Boolean(config?.sourceOnDemand);
|
|
elements.sourceRequired.hidden = editing;
|
|
elements.sourceHelp.textContent = editing
|
|
? `当前地址:${config.sourceMasked}。留空将保留原地址和凭据。`
|
|
: "例如 rtsp://user:password@camera.local/live。凭据不会持久化到浏览器。";
|
|
elements.formAlert.hidden = true;
|
|
clearFieldError(elements.pathName, elements.pathNameError);
|
|
clearFieldError(elements.sourceURL, elements.sourceError);
|
|
elements.pathDialog.showModal();
|
|
window.setTimeout(() => (editing ? elements.sourceURL : elements.pathName).focus(), 0);
|
|
}
|
|
|
|
function encodePathName(name) {
|
|
return name.split("/").map((part) => encodeURIComponent(part)).join("/");
|
|
}
|
|
|
|
function setButtonBusy(button, busy, busyText, normalText) {
|
|
button.disabled = busy;
|
|
button.textContent = busy ? busyText : normalText;
|
|
}
|
|
|
|
async function submitPath(event) {
|
|
event.preventDefault();
|
|
const editing = Boolean(state.editingName);
|
|
const name = elements.pathName.value.trim();
|
|
const source = elements.sourceURL.value.trim();
|
|
const nameError = validatePathName(name);
|
|
const sourceError = validateSource(source, !editing);
|
|
|
|
nameError
|
|
? setFieldError(elements.pathName, elements.pathNameError, nameError)
|
|
: clearFieldError(elements.pathName, elements.pathNameError);
|
|
sourceError
|
|
? setFieldError(elements.sourceURL, elements.sourceError, sourceError)
|
|
: clearFieldError(elements.sourceURL, elements.sourceError);
|
|
|
|
if (nameError || sourceError) {
|
|
(nameError ? elements.pathName : elements.sourceURL).focus();
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
rtspTransport: elements.transport.value,
|
|
sourceOnDemand: elements.sourceOnDemand.checked,
|
|
};
|
|
if (source) payload.source = source;
|
|
|
|
const normalText = editing ? "保存修改" : "添加路径";
|
|
setButtonBusy(elements.pathSubmit, true, "正在保存…", normalText);
|
|
elements.formAlert.hidden = true;
|
|
|
|
try {
|
|
const endpoint = editing ? "patch" : "add";
|
|
const method = editing ? "PATCH" : "POST";
|
|
await apiFetch(`/v3/config/paths/${endpoint}/${encodePathName(name)}`, {
|
|
method,
|
|
body: JSON.stringify(payload),
|
|
});
|
|
elements.pathDialog.close();
|
|
showToast(editing ? `已更新路径 ${name}` : `已添加路径 ${name}`);
|
|
await loadData();
|
|
} catch (error) {
|
|
elements.formAlert.textContent = error.message;
|
|
elements.formAlert.hidden = false;
|
|
} finally {
|
|
setButtonBusy(elements.pathSubmit, false, "", normalText);
|
|
}
|
|
}
|
|
|
|
function openDeleteDialog(name) {
|
|
state.deletingName = name;
|
|
elements.deletePathName.textContent = name;
|
|
elements.deleteAlert.hidden = true;
|
|
elements.deleteDialog.showModal();
|
|
window.setTimeout(() => elements.deleteSubmit.focus(), 0);
|
|
}
|
|
|
|
async function submitDelete(event) {
|
|
event.preventDefault();
|
|
const name = state.deletingName;
|
|
if (!name) return;
|
|
setButtonBusy(elements.deleteSubmit, true, "正在删除…", "删除路径");
|
|
elements.deleteAlert.hidden = true;
|
|
try {
|
|
await apiFetch(`/v3/config/paths/delete/${encodePathName(name)}`, { method: "DELETE" });
|
|
elements.deleteDialog.close();
|
|
showToast(`已删除路径 ${name}`);
|
|
await loadData();
|
|
} catch (error) {
|
|
elements.deleteAlert.textContent = error.message;
|
|
elements.deleteAlert.hidden = false;
|
|
} finally {
|
|
setButtonBusy(elements.deleteSubmit, false, "", "删除路径");
|
|
}
|
|
}
|
|
|
|
function extractPort(address, fallback) {
|
|
const match = String(address || "").match(/:(\d+)$/);
|
|
return match ? match[1] : String(fallback);
|
|
}
|
|
|
|
function browserHost() {
|
|
const hostname = window.location.hostname || "localhost";
|
|
return hostname.includes(":") ? `[${hostname}]` : hostname;
|
|
}
|
|
|
|
function encodedMediaPath(name) {
|
|
return name.split("/").map((part) => encodeURIComponent(part)).join("/");
|
|
}
|
|
|
|
function outputAddresses(name) {
|
|
const host = browserHost();
|
|
const path = encodedMediaPath(name);
|
|
const rtspPort = extractPort(state.global.rtspAddress, 8554);
|
|
const hlsPort = extractPort(state.global.hlsAddress, 8888);
|
|
const webrtcPort = extractPort(state.global.webrtcAddress, 8889);
|
|
const hlsScheme = state.global.hlsEncryption ? "https" : "http";
|
|
const webrtcScheme = state.global.webrtcEncryption ? "https" : "http";
|
|
return {
|
|
rtsp: `rtsp://${host}:${rtspPort}/${path}`,
|
|
webrtc: `${webrtcScheme}://${host}:${webrtcPort}/${path}`,
|
|
hls: `${hlsScheme}://${host}:${hlsPort}/${path}/`,
|
|
};
|
|
}
|
|
|
|
function renderOutputAddresses(addresses) {
|
|
const fragment = document.createDocumentFragment();
|
|
const entries = [
|
|
["RTSP", addresses.rtsp],
|
|
["WebRTC", addresses.webrtc],
|
|
["HLS", addresses.hls],
|
|
];
|
|
for (const [label, value] of entries) {
|
|
const item = create("div", { className: "output-item" });
|
|
const labelNode = create("label", { text: label });
|
|
const valueNode = create("span", { className: "output-value", text: value });
|
|
const copyButton = actionButton(`复制 ${label} 地址`, "button-secondary", () => copyText(value));
|
|
item.append(labelNode, valueNode, copyButton);
|
|
fragment.append(item);
|
|
}
|
|
elements.outputAddresses.replaceChildren(fragment);
|
|
}
|
|
|
|
function setPreviewProtocol(protocol) {
|
|
state.previewProtocol = protocol;
|
|
const addresses = outputAddresses(state.previewName);
|
|
const isWebRTC = protocol === "webrtc";
|
|
elements.webrtcTab.setAttribute("aria-selected", String(isWebRTC));
|
|
elements.hlsTab.setAttribute("aria-selected", String(!isWebRTC));
|
|
elements.previewFrame.src = isWebRTC ? addresses.webrtc : addresses.hls;
|
|
elements.previewHelp.textContent = isWebRTC
|
|
? "WebRTC 低延迟优先;连接失败时可切换到 HLS。"
|
|
: "HLS 兼容性优先,通常延迟高于 WebRTC。";
|
|
}
|
|
|
|
function openPreview(name) {
|
|
state.previewName = name;
|
|
elements.previewTitle.textContent = `预览 · ${name}`;
|
|
const addresses = outputAddresses(name);
|
|
renderOutputAddresses(addresses);
|
|
elements.previewDialog.showModal();
|
|
setPreviewProtocol("webrtc");
|
|
window.setTimeout(() => elements.webrtcTab.focus(), 0);
|
|
}
|
|
|
|
async function copyText(value) {
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(value);
|
|
} else {
|
|
const input = create("textarea");
|
|
input.value = value;
|
|
input.setAttribute("readonly", "");
|
|
input.className = "visually-hidden";
|
|
document.body.append(input);
|
|
input.select();
|
|
document.execCommand("copy");
|
|
input.remove();
|
|
}
|
|
showToast("地址已复制");
|
|
} catch (_) {
|
|
showToast("复制失败,请手动选择地址", true);
|
|
}
|
|
}
|
|
|
|
function showToast(message, danger = false) {
|
|
window.clearTimeout(state.toastTimer);
|
|
elements.toast.textContent = message;
|
|
elements.toast.style.background = danger ? "var(--danger)" : "var(--foreground)";
|
|
elements.toast.hidden = false;
|
|
state.toastTimer = window.setTimeout(() => {
|
|
elements.toast.hidden = true;
|
|
}, 4000);
|
|
}
|
|
|
|
function closeDialog(id) {
|
|
const dialog = byID(id);
|
|
if (dialog?.open) dialog.close();
|
|
}
|
|
|
|
function cacheElements() {
|
|
Object.assign(elements, {
|
|
apiStatus: byID("api-status"),
|
|
refreshButton: byID("refresh-button"),
|
|
addButton: byID("add-button"),
|
|
statTotal: byID("stat-total"),
|
|
statOnline: byID("stat-online"),
|
|
statOffline: byID("stat-offline"),
|
|
statIdle: byID("stat-idle"),
|
|
searchInput: byID("search-input"),
|
|
statusFilter: byID("status-filter"),
|
|
pageAlert: byID("page-alert"),
|
|
pageAlertTitle: byID("page-alert-title"),
|
|
pageAlertMessage: byID("page-alert-message"),
|
|
alertRetryButton: byID("alert-retry-button"),
|
|
loadingState: byID("loading-state"),
|
|
emptyState: byID("empty-state"),
|
|
emptyTitle: byID("empty-title"),
|
|
emptyMessage: byID("empty-message"),
|
|
emptyAddButton: byID("empty-add-button"),
|
|
pathsContent: byID("paths-content"),
|
|
tableBody: byID("paths-table-body"),
|
|
cardList: byID("paths-card-list"),
|
|
pathDialog: byID("path-dialog"),
|
|
pathForm: byID("path-form"),
|
|
pathDialogTitle: byID("path-dialog-title"),
|
|
formAlert: byID("form-alert"),
|
|
pathName: byID("path-name"),
|
|
pathNameError: byID("path-name-error"),
|
|
sourceURL: byID("source-url"),
|
|
sourceToggle: byID("source-toggle"),
|
|
sourceRequired: byID("source-required"),
|
|
sourceHelp: byID("source-help"),
|
|
sourceError: byID("source-error"),
|
|
transport: byID("transport"),
|
|
sourceOnDemand: byID("source-on-demand"),
|
|
pathSubmit: byID("path-submit"),
|
|
deleteDialog: byID("delete-dialog"),
|
|
deleteForm: byID("delete-form"),
|
|
deletePathName: byID("delete-path-name"),
|
|
deleteAlert: byID("delete-alert"),
|
|
deleteSubmit: byID("delete-submit"),
|
|
previewDialog: byID("preview-dialog"),
|
|
previewTitle: byID("preview-title"),
|
|
previewFrame: byID("preview-frame"),
|
|
previewHelp: byID("preview-help"),
|
|
webrtcTab: byID("webrtc-tab"),
|
|
hlsTab: byID("hls-tab"),
|
|
outputAddresses: byID("output-addresses"),
|
|
toast: byID("toast"),
|
|
});
|
|
}
|
|
|
|
function bindEvents() {
|
|
elements.refreshButton.addEventListener("click", loadData);
|
|
elements.alertRetryButton.addEventListener("click", loadData);
|
|
elements.addButton.addEventListener("click", () => openPathDialog());
|
|
elements.emptyAddButton.addEventListener("click", () => openPathDialog());
|
|
elements.searchInput.addEventListener("input", render);
|
|
elements.statusFilter.addEventListener("change", render);
|
|
elements.pathForm.addEventListener("submit", submitPath);
|
|
elements.deleteForm.addEventListener("submit", submitDelete);
|
|
elements.sourceToggle.addEventListener("click", () => {
|
|
const showing = elements.sourceURL.type === "text";
|
|
elements.sourceURL.type = showing ? "password" : "text";
|
|
elements.sourceToggle.textContent = showing ? "显示" : "隐藏";
|
|
elements.sourceToggle.setAttribute("aria-pressed", String(!showing));
|
|
elements.sourceURL.focus();
|
|
});
|
|
elements.webrtcTab.addEventListener("click", () => setPreviewProtocol("webrtc"));
|
|
elements.hlsTab.addEventListener("click", () => setPreviewProtocol("hls"));
|
|
|
|
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
|
|
button.addEventListener("click", () => closeDialog(button.dataset.closeDialog));
|
|
});
|
|
|
|
elements.previewDialog.addEventListener("close", () => {
|
|
elements.previewFrame.src = "about:blank";
|
|
state.previewName = null;
|
|
});
|
|
}
|
|
|
|
function initialize() {
|
|
cacheElements();
|
|
bindEvents();
|
|
loadData();
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", initialize);
|
|
})();
|