(() => { "use strict"; const ListTools = window.MediaMTXAdminList; const API_PAGE_SIZE = 100; const API_PAGE_CONCURRENCY = 4; const API = { info: "/v3/info", configs: "/v3/config/paths/list", paths: "/v3/paths/list", global: "/v3/config/global/get", }; const AUTH_STORAGE_KEY = "mediamtx.admin.authorization"; const state = { configs: [], runtime: new Map(), global: {}, editingName: null, deletingName: null, previewName: null, previewProtocol: "webrtc", loading: false, toastTimer: null, authorization: "", currentPage: 1, pageSize: 50, }; 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:••••@", ); } function readStoredAuthorization() { try { return window.sessionStorage.getItem(AUTH_STORAGE_KEY) || ""; } catch (_) { return ""; } } function saveAuthorization(value) { state.authorization = value; try { window.sessionStorage.setItem(AUTH_STORAGE_KEY, value); } catch (_) { // The current tab can still stay authenticated when storage is unavailable. } } function clearAuthorization() { state.authorization = ""; try { window.sessionStorage.removeItem(AUTH_STORAGE_KEY); } catch (_) { // Nothing else is required when storage is unavailable. } } function basicAuthorization(username, password) { const bytes = new TextEncoder().encode(`${username}:${password}`); let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return `Basic ${window.btoa(binary)}`; } async function apiFetch(url, options = {}) { const headers = new Headers(options.headers || {}); if (options.body !== undefined) headers.set("Content-Type", "application/json"); const authorization = Object.prototype.hasOwnProperty.call(options, "authorization") ? options.authorization : state.authorization; if (authorization) headers.set("Authorization", authorization); const requestOptions = { ...options }; const handleUnauthorized = requestOptions.handleUnauthorized !== false; delete requestOptions.authorization; delete requestOptions.handleUnauthorized; let response; try { response = await fetch(url, { ...requestOptions, 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) { if (handleUnauthorized && state.authorization) { endSession("登录已失效,请重新登录。"); } 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 showLogin(message = "") { elements.appView.hidden = true; elements.loginView.hidden = false; elements.loginError.textContent = message; elements.loginUsername.removeAttribute("aria-invalid"); elements.loginPassword.removeAttribute("aria-invalid"); elements.loginPassword.value = ""; elements.loginPassword.type = "password"; elements.loginPasswordToggle.textContent = "显示"; elements.loginPasswordToggle.setAttribute("aria-pressed", "false"); elements.skipLink.href = "#login-main"; window.setTimeout(() => elements.loginUsername.focus(), 0); } function showApp() { elements.loginView.hidden = true; elements.appView.hidden = false; elements.loginError.textContent = ""; elements.skipLink.href = "#main-content"; window.setTimeout(() => elements.mainContent.focus(), 0); } function endSession(message = "") { clearAuthorization(); document.querySelectorAll("dialog[open]").forEach((dialog) => dialog.close()); elements.previewFrame.src = "about:blank"; showLogin(message); } function togglePassword(input, button) { const showing = input.type === "text"; input.type = showing ? "password" : "text"; button.textContent = showing ? "显示" : "隐藏"; button.setAttribute("aria-pressed", String(!showing)); input.focus(); } async function submitLogin(event) { event.preventDefault(); const username = elements.loginUsername.value.trim(); const password = elements.loginPassword.value; elements.loginError.textContent = ""; elements.loginUsername.removeAttribute("aria-invalid"); elements.loginPassword.removeAttribute("aria-invalid"); if (!username) { elements.loginUsername.setAttribute("aria-invalid", "true"); elements.loginError.textContent = "请输入用户名。"; elements.loginUsername.focus(); return; } if (!password) { elements.loginPassword.setAttribute("aria-invalid", "true"); elements.loginError.textContent = "请输入密码。"; elements.loginPassword.focus(); return; } setButtonBusy(elements.loginSubmit, true, "正在登录…", "登录"); try { const authorization = basicAuthorization(username, password); await apiFetch(API.info, { authorization, handleUnauthorized: false }); saveAuthorization(authorization); showApp(); await loadData(); } catch (error) { const apiError = error instanceof APIError ? error : new APIError(0, String(error)); elements.loginPassword.setAttribute("aria-invalid", "true"); elements.loginError.textContent = apiError.status === 401 ? "用户名、密码或 API 权限不正确。" : apiError.message; elements.loginPassword.select(); } finally { setButtonBusy(elements.loginSubmit, false, "", "登录"); } } async function restoreSession() { const authorization = readStoredAuthorization(); if (!authorization) { showLogin(); return; } state.authorization = authorization; setButtonBusy(elements.loginSubmit, true, "正在验证…", "登录"); try { await apiFetch(API.info, { handleUnauthorized: false }); showApp(); await loadData(); } catch (error) { clearAuthorization(); const apiError = error instanceof APIError ? error : new APIError(0, String(error)); showLogin(apiError.status === 401 ? "登录已失效,请重新登录。" : apiError.message); } finally { setButtonBusy(elements.loginSubmit, false, "", "登录"); } } 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; elements.loadingText.textContent = "正在加载设备数据…"; setAPIStatus("loading", "正在连接 API"); } } function normalizeAPIError(error) { if (error instanceof APIError) return error; const status = Number.isInteger(error?.status) ? error.status : 0; return new APIError(status, redactSensitiveText(error?.message || String(error))); } function loadingProgressReporter() { const resources = new Map(); return (progress) => { if (!state.loading) return; resources.set(progress.label, progress); let loaded = 0; let total = 0; for (const item of resources.values()) { loaded += item.loaded; total += item.total; } const message = total > 0 ? `正在分批加载数据 ${Math.min(loaded, total)} / ${total}…` : "正在加载设备数据…"; elements.loadingText.textContent = message; setAPIStatus("loading", total > 0 ? `正在加载 ${Math.min(loaded, total)}/${total}` : "正在连接 API"); }; } async function loadData() { if (state.loading) return; setLoading(true); hidePageError(); try { const reportProgress = loadingProgressReporter(); const pagedFetch = ListTools.limitConcurrency(apiFetch, API_PAGE_CONCURRENCY); const [configData, pathData, globalData] = await Promise.all([ ListTools.fetchAllPages(pagedFetch, API.configs, { label: "设备配置", pageSize: API_PAGE_SIZE, concurrency: API_PAGE_CONCURRENCY, onProgress: reportProgress, }), ListTools.fetchAllPages(pagedFetch, API.paths, { label: "运行状态", pageSize: API_PAGE_SIZE, concurrency: API_PAGE_CONCURRENCY, onProgress: reportProgress, }), apiFetch(API.global).catch(() => ({})), ]); const configs = ListTools.uniqueSortedByName(configData.items) .filter(isRTSPSource) .map(scrubConfig); const runtimeItems = ListTools.uniqueSortedByName(pathData.items); state.configs = configs; state.runtime = new Map(runtimeItems.map((item) => [item.name, item])); state.global = globalData || {}; setAPIStatus("ok", "API 已连接"); render(); } catch (error) { const apiError = normalizeAPIError(error); if (apiError.status === 401) return; if (state.configs.length > 0) render(); else { elements.pathsContent.hidden = true; elements.emptyState.hidden = true; updateStats([]); } showPageError(apiError); } finally { setLoading(false); } } function filteredConfigs() { return ListTools.filterConfigs( state.configs, elements.searchInput.value, elements.statusFilter.value, (config) => getPathState(config).key, ); } function updateStats(configs) { const counts = ListTools.countStatuses(configs, (config) => getPathState(config).key); elements.statTotal.textContent = String(counts.total); 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 updatePagination(pageData) { state.currentPage = pageData.page; state.pageSize = pageData.pageSize; elements.pageSize.value = String(pageData.pageSize); elements.paginationSummary.textContent = `${pageData.start}–${pageData.end} / 共 ${pageData.totalItems} 条`; elements.paginationPage.textContent = `${pageData.page} / ${pageData.totalPages} 页`; elements.previousPage.disabled = pageData.page <= 1; elements.nextPage.disabled = pageData.page >= pageData.totalPages; } 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; } const pageData = ListTools.paginateItems(configs, state.currentPage, state.pageSize); elements.emptyState.hidden = true; elements.pathsContent.hidden = false; renderTable(pageData.items); renderCards(pageData.items); updatePagination(pageData); } 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, { skipLink: byID("skip-link"), loginView: byID("login-view"), loginForm: byID("login-form"), loginUsername: byID("login-username"), loginPassword: byID("login-password"), loginPasswordToggle: byID("login-password-toggle"), loginError: byID("login-error"), loginSubmit: byID("login-submit"), appView: byID("app-view"), mainContent: byID("main-content"), apiStatus: byID("api-status"), refreshButton: byID("refresh-button"), addButton: byID("add-button"), logoutButton: byID("logout-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"), paginationSummary: byID("pagination-summary"), paginationPage: byID("pagination-page"), pageSize: byID("page-size"), previousPage: byID("previous-page"), nextPage: byID("next-page"), loadingText: byID("loading-text"), 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.loginForm.addEventListener("submit", submitLogin); elements.loginPasswordToggle.addEventListener("click", () => { togglePassword(elements.loginPassword, elements.loginPasswordToggle); }); elements.logoutButton.addEventListener("click", () => endSession()); elements.refreshButton.addEventListener("click", loadData); elements.alertRetryButton.addEventListener("click", loadData); elements.addButton.addEventListener("click", () => openPathDialog()); elements.emptyAddButton.addEventListener("click", () => openPathDialog()); elements.searchInput.addEventListener("input", () => { state.currentPage = 1; render(); }); elements.statusFilter.addEventListener("change", () => { state.currentPage = 1; render(); }); elements.pageSize.addEventListener("change", () => { state.pageSize = Number(elements.pageSize.value); state.currentPage = 1; render(); }); elements.previousPage.addEventListener("click", () => { state.currentPage -= 1; render(); }); elements.nextPage.addEventListener("click", () => { state.currentPage += 1; render(); }); elements.pathForm.addEventListener("submit", submitPath); elements.deleteForm.addEventListener("submit", submitDelete); elements.sourceToggle.addEventListener("click", () => { togglePassword(elements.sourceURL, elements.sourceToggle); }); 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(); restoreSession(); } document.addEventListener("DOMContentLoaded", initialize); })();