import Hls from "hls.js"; import { showStatusMessage } from "./ui.js"; import { createHUDPanel } from "./hud-panels.js"; // Naming convention: // - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide // - #tv-panel is the inner live tab pane // - #news-panel is the inner aggregation-news tab pane const TV_STREAMS_API = "/api/v1/tv/streams"; const TV_PROXY_API = "/api/v1/tv/proxy"; const TV_STATUS_MESSAGE = { idle: "等待加载直播源", syncing: "正在同步直播源...", empty: "暂无可播放直播源", iframeReady: "直播页已加载", videoReady: "视频流已加载", videoError: "当前视频流不可播放,请尝试其他频道", externalOnly: "当前频道仅支持外部打开", loadFailed: "电视直播源加载失败", }; let tvPayload = null; let currentSourceId = ""; let initialized = false; let refreshPromise = null; let hlsPlayer = null; let hlsRecoveryAttempts = 0; let metaAutoCollapseTimer = null; let mediaPanel = null; let activeTab = "live"; const failedSourceIds = new Set(); let probeTimer = null; let reformCleanupTimer = null; const tabPanelState = { live: null, news: null, }; let mobileMetaCollapsed = false; const META_AUTO_COLLAPSE_DELAY = 2500; const PROBE_INTERVAL_MS = 2 * 60 * 1000; const DEFAULT_HUD_OFFSET_PX = 20; const MIN_NEWS_TAB_HEIGHT_PX = 280; const PANEL_RESIZE_MARGIN_PX = 12; const TV_PANEL_MIN_WIDTH_PX = 360; const TV_PANEL_MIN_HEIGHT_PX = 340; const REFORM_CLEANUP_MS = 280; const REFORM_RESTORE_ANCHOR_DATA_KEY = "reformRestoreAnchor"; const HLS_MAX_RECOVERY_ATTEMPTS = 3; const HLS_RETRY_CONFIG = { maxNumRetry: 4, retryDelayMs: 1500, maxRetryDelayMs: 8000, backoff: "exponential", }; function getElements() { const isMobile = document.body.classList.contains("layout-mode-mobile"); return { // Outer media shell node. panel: document.getElementById("media-panel"), toggleBtn: document.getElementById("toggle-tv"), select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"), title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"), meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"), catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"), status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"), notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"), iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"), video: document.getElementById(isMobile ? "mobile-tv-video" : "tv-video"), empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"), refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"), openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"), metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"), metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"), liveHeaderControls: document.getElementById("tv-header-controls-live"), newsHeaderControls: document.getElementById("tv-header-controls-news"), liveTabBtn: document.getElementById("tv-tab-live"), newsTabBtn: document.getElementById("tv-tab-news"), // Inner tab panes. livePane: document.getElementById("tv-panel"), newsPane: document.getElementById("news-panel"), }; } function isMobileLayout() { return document.body.classList.contains("layout-mode-mobile"); } function syncMetaToggleState(collapsed) { const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar"); if (mobileOverviewBar instanceof HTMLElement) { mobileOverviewBar.setAttribute("aria-expanded", collapsed ? "false" : "true"); } const desktopToggle = document.getElementById("tv-meta-toggle"); if (desktopToggle instanceof HTMLButtonElement) { desktopToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); desktopToggle.setAttribute( "aria-label", collapsed ? "展开新闻直播内容" : "折叠新闻直播内容", ); desktopToggle.title = collapsed ? "展开新闻直播内容" : "折叠新闻直播内容"; } } function isMetaCollapsed() { if (isMobileLayout()) { return mobileMetaCollapsed; } return mediaPanel?.isCollapsed() ?? false; } function setMetaCollapsed(collapsed) { if (isMobileLayout()) { const { metaWrap } = getElements(); mobileMetaCollapsed = Boolean(collapsed); metaWrap?.classList.toggle("is-collapsed", mobileMetaCollapsed); syncMetaToggleState(mobileMetaCollapsed); return; } mediaPanel?.setCollapsed(collapsed); syncMetaToggleState(Boolean(collapsed)); } function syncPanelActiveTab(tab = activeTab) { const { panel } = getElements(); if (panel instanceof HTMLElement) { panel.dataset.activeTab = tab; } } function syncNewsDefaultMaxHeight() { const { panel } = getElements(); if (!(panel instanceof HTMLElement)) return; const earthStats = document.getElementById("earth-stats"); const hudOffset = Number.parseFloat( getComputedStyle(document.documentElement).getPropertyValue("--hud-offset"), ); const resolvedOffset = Number.isFinite(hudOffset) ? hudOffset : DEFAULT_HUD_OFFSET_PX; if (!(earthStats instanceof HTMLElement)) { panel.style.removeProperty("--tv-news-default-max-height"); return; } const statsRect = earthStats.getBoundingClientRect(); const availableHeight = Math.max( Math.round(MIN_NEWS_TAB_HEIGHT_PX * getHudScale()), Math.floor(window.innerHeight - resolvedOffset - statsRect.bottom), ); panel.style.setProperty("--tv-news-default-max-height", `${availableHeight}px`); } function autoExpandMeta() { if (isMobileLayout()) { clearTimeout(metaAutoCollapseTimer); return; } clearTimeout(metaAutoCollapseTimer); setMetaCollapsed(false); metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY); } function clearPanelPositioningForResize(panel) { panel.style.left = `${panel.offsetLeft}px`; panel.style.top = `${panel.offsetTop}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.transform = "none"; panel.dataset.dragged = "true"; } function readPanelLayoutState(panel) { return { width: panel.style.width || "", height: panel.style.height || "", resized: panel.dataset.resized === "true", }; } function resetPanelLayoutState(panel) { panel.style.width = ""; panel.style.height = ""; delete panel.dataset.resized; } function captureTabState(tab = activeTab) { const { panel } = getElements(); if (!(panel instanceof HTMLElement)) return; tabPanelState[tab] = { layout: readPanelLayoutState(panel), metaCollapsed: tab === "live" ? isMetaCollapsed() : null, }; } function restoreTabState(tab, panel, container, anchor = null) { if (!(panel instanceof HTMLElement)) return; const snapshot = tabPanelState[tab]; if (!snapshot?.layout) { resetPanelLayoutState(panel); return; } const { layout } = snapshot; panel.style.width = layout.width; panel.style.height = layout.height; if (layout.resized) { panel.dataset.resized = "true"; } else { delete panel.dataset.resized; } if (tab === "live" && snapshot.metaCollapsed !== null) { setMetaCollapsed(snapshot.metaCollapsed); } requestAnimationFrame(() => { if (anchor) { const panelRect = panel.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale()); const targetLeft = anchor.right - containerRect.left - panelRect.width; const targetTop = anchor.bottom - containerRect.top - panelRect.height; const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin); const maxTop = Math.max(0, containerRect.height - panelRect.height - margin); const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft)); const clampedTop = Math.min(maxTop, Math.max(0, targetTop)); panel.style.left = `${clampedLeft}px`; panel.style.top = `${clampedTop}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.transform = "none"; panel.dataset.dragged = "true"; } else { panel.style.right = ""; panel.style.bottom = ""; panel.style.left = ""; panel.style.top = ""; panel.style.transform = ""; delete panel.dataset.dragged; } }); } function getHudScale() { const scale = Number.parseFloat( getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), ); return Number.isFinite(scale) && scale > 0 ? scale : 1; } function clampPanelToContainer(panel, container) { if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return; if (panel.dataset.dragged !== "true") return; const containerRect = container.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect(); const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale()); const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin); const maxTop = Math.max(0, containerRect.height - panelRect.height - margin); const currentLeft = panelRect.left - containerRect.left; const currentTop = panelRect.top - containerRect.top; const clampedLeft = Math.min(maxLeft, Math.max(0, currentLeft)); const clampedTop = Math.min(maxTop, Math.max(0, currentTop)); panel.style.left = `${clampedLeft}px`; panel.style.top = `${clampedTop}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.transform = "none"; } function setupResizeHandle() { const { panel } = getElements(); const container = document.getElementById("container"); if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return; let resizing = false; let activeEdge = ""; const resizeStart = { pointerX: 0, pointerY: 0, width: 0, height: 0, left: 0, top: 0, }; const stopResize = () => { resizing = false; activeEdge = ""; panel.classList.remove("is-resizing"); document.body.style.userSelect = ""; }; const onMove = (event) => { if (!resizing) return; const containerRect = container.getBoundingClientRect(); const hudScale = getHudScale(); const minWidth = Math.max(320, Math.round(TV_PANEL_MIN_WIDTH_PX * hudScale)); const minHeight = Math.max(260, Math.round(TV_PANEL_MIN_HEIGHT_PX * hudScale)); const dx = event.clientX - resizeStart.pointerX; const dy = event.clientY - resizeStart.pointerY; if (activeEdge.includes("r")) { const maxW = containerRect.width - resizeStart.left - PANEL_RESIZE_MARGIN_PX; panel.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`; } if (activeEdge.includes("l")) { const newW = Math.max(minWidth, resizeStart.width - dx); panel.style.width = `${newW}px`; panel.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`; } if (activeEdge.includes("b")) { const maxH = containerRect.height - resizeStart.top - PANEL_RESIZE_MARGIN_PX; panel.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`; } }; panel.querySelectorAll(".tv-panel-edge[data-edge]").forEach((edgeEl) => { edgeEl.addEventListener("pointerdown", (event) => { if (container.classList.contains("layout-expanded")) return; event.preventDefault(); event.stopPropagation(); activeEdge = edgeEl.dataset.edge ?? ""; resizing = true; resizeStart.pointerX = event.clientX; resizeStart.pointerY = event.clientY; clearPanelPositioningForResize(panel); panel.dataset.resized = "true"; const rect = panel.getBoundingClientRect(); const cRect = container.getBoundingClientRect(); resizeStart.width = rect.width; resizeStart.height = rect.height; resizeStart.left = rect.left - cRect.left; resizeStart.top = rect.top - cRect.top; panel.style.width = `${resizeStart.width}px`; panel.style.height = `${resizeStart.height}px`; panel.style.minHeight = ""; panel.classList.add("is-resizing"); document.body.style.userSelect = "none"; edgeEl.setPointerCapture?.(event.pointerId); }); edgeEl.addEventListener("pointermove", onMove); edgeEl.addEventListener("pointerup", stopResize); edgeEl.addEventListener("pointercancel", stopResize); edgeEl.addEventListener("lostpointercapture", stopResize); }); } function updateToggleButton(visible) { const { toggleBtn } = getElements(); if (!toggleBtn) return; const icon = toggleBtn.querySelector(".material-symbols-rounded"); const isLiveTab = activeTab === "live"; toggleBtn.classList.toggle("active", visible); if (icon) { icon.textContent = isLiveTab ? "live_tv" : "newspaper"; } const title = visible ? (isLiveTab ? "切换到态势新闻" : "切换到新闻直播") : (isLiveTab ? "打开新闻直播" : "打开态势新闻"); toggleBtn.title = title; toggleBtn.setAttribute("aria-label", title); const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip"); if (tooltip) { tooltip.textContent = title; } } function syncSettingsToggle(visible) { const input = document.querySelector('[data-settings-panel="media-panel"]'); if (input instanceof HTMLInputElement) { input.checked = visible; } } function setPanelVisible(visible) { const { panel } = getElements(); if (!panel) return; mediaPanel?.setVisible(visible); document.body.classList.toggle("earth-media-open", visible); updateToggleButton(visible); syncSettingsToggle(visible); window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", { detail: { visible }, })); } export function setTVPanelVisible(visible) { setPanelVisible(visible); } function clearReformState() { const { panel } = getElements(); if (!(panel instanceof HTMLElement)) return; panel.classList.remove("is-reforming"); panel.style.height = ""; if (panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] === "true") { panel.style.top = ""; panel.style.bottom = ""; delete panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY]; } if (reformCleanupTimer) { clearTimeout(reformCleanupTimer); reformCleanupTimer = null; } } function animateTabReform(applyChange) { const { panel } = getElements(); const container = document.getElementById("container"); if (!(panel instanceof HTMLElement)) { applyChange(); return; } if (!(container instanceof HTMLElement)) { applyChange(); return; } if (panel.dataset.resized === "true") { applyChange(); requestAnimationFrame(() => { clampPanelToContainer(panel, container); }); return; } if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) { applyChange(); return; } clearReformState(); const reformStartRect = panel.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const reformSnapshot = { height: reformStartRect.height, anchoredBottom: reformStartRect.bottom - containerRect.top, shouldRestoreDefaultAnchoring: panel.dataset.dragged !== "true", }; if (reformSnapshot.shouldRestoreDefaultAnchoring) { panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] = "true"; panel.style.bottom = "auto"; } panel.style.top = `${reformSnapshot.anchoredBottom - reformSnapshot.height}px`; panel.style.height = `${reformSnapshot.height}px`; panel.classList.add("is-reforming"); void panel.offsetHeight; applyChange(); panel.style.height = "auto"; const targetHeight = panel.getBoundingClientRect().height; panel.style.height = `${reformSnapshot.height}px`; void panel.offsetHeight; const targetTop = reformSnapshot.anchoredBottom - targetHeight; const finalizeReform = () => { panel.removeEventListener("transitionend", handleReformTransitionEnd); clearReformState(); }; const handleReformTransitionEnd = (event) => { if (event.target === panel && event.propertyName === "height") { finalizeReform(); } }; panel.addEventListener("transitionend", handleReformTransitionEnd); reformCleanupTimer = window.setTimeout(finalizeReform, REFORM_CLEANUP_MS); requestAnimationFrame(() => { panel.style.top = `${targetTop}px`; panel.style.height = `${targetHeight}px`; }); } function updateTabState(target, isActive, activeClassName = "") { if (!(target instanceof HTMLElement)) return; target.hidden = !isActive; if (activeClassName) { target.classList.toggle(activeClassName, isActive); } } function updateTabButtonState(button, isActive) { if (!(button instanceof HTMLButtonElement)) return; button.classList.toggle("media-panel-tab--active", isActive); button.setAttribute("aria-selected", isActive ? "true" : "false"); } function setActiveTab(tab) { const nextTab = tab === "news" ? "news" : "live"; if (activeTab === nextTab) return; captureTabState(activeTab); const { panel } = getElements(); const targetSnapshot = tabPanelState[nextTab]; const currentIsCustom = panel instanceof HTMLElement && panel.dataset.resized === "true"; const targetIsCustom = Boolean(targetSnapshot?.layout?.resized); const container = document.getElementById("container"); const currentAnchor = panel instanceof HTMLElement && container instanceof HTMLElement ? (() => { const panelRect = panel.getBoundingClientRect(); return { right: panelRect.right, bottom: panelRect.bottom, }; })() : null; const applyTabSwitch = (restoreLayoutState = false) => { activeTab = nextTab; syncPanelActiveTab(nextTab); syncNewsDefaultMaxHeight(); const { liveTabBtn, newsTabBtn, liveHeaderControls, newsHeaderControls, livePane, newsPane, } = getElements(); updateTabButtonState(liveTabBtn, nextTab === "live"); updateTabButtonState(newsTabBtn, nextTab === "news"); updateTabState(liveHeaderControls, nextTab === "live"); updateTabState(newsHeaderControls, nextTab === "news"); updateTabState(livePane, nextTab === "live", "tv-tab-pane--active"); updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active"); updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden")); if ( restoreLayoutState && panel instanceof HTMLElement && container instanceof HTMLElement ) { restoreTabState(nextTab, panel, container, currentAnchor); } requestAnimationFrame(() => { captureTabState(nextTab); }); window.dispatchEvent(new CustomEvent("earth:tv-tab-change", { detail: { tab: nextTab }, })); }; if (currentIsCustom || targetIsCustom) { applyTabSwitch(true); return; } animateTabReform(() => applyTabSwitch(false)); } export function openTVPanelTab(tab = "live") { setPanelVisible(true); if (activeTab === tab) return; setActiveTab(tab); } export function isTVPanelVisible() { return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden"); } export function getActiveTVTab() { return activeTab; } function getEmbeddedUrl(source) { if (!source) return ""; if (source.source_type === "youtube" && source.youtube_video_id) { const videoId = encodeURIComponent(source.youtube_video_id); return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&playsinline=1&rel=0`; } if (source.source_type === "external") return ""; if (source.source_type === "video" || source.source_type === "hls") { return ""; } return source.embed_url || source.homepage_url || ""; } function buildProxyUrl(url) { if (!url) return ""; return `${TV_PROXY_API}?url=${encodeURIComponent(url)}`; } function getVideoUrl(source) { if (!source) return ""; if (source.source_type !== "video" && source.source_type !== "hls") { return ""; } return buildProxyUrl(source.stream_url || source.embed_url || ""); } function destroyHlsPlayer() { if (hlsPlayer) { hlsPlayer.destroy(); hlsPlayer = null; } hlsRecoveryAttempts = 0; } function showEmbeddedFallback(source, reasonMessage = TV_STATUS_MESSAGE.videoError) { const { iframe, video, empty } = getElements(); const embeddedUrl = getEmbeddedUrl(source); if (!embeddedUrl) { setPanelMessage(reasonMessage); return false; } destroyHlsPlayer(); if (video) { video.removeAttribute("src"); video.hidden = true; video.load(); } if (iframe) { iframe.hidden = false; if (iframe.src !== embeddedUrl) { iframe.src = embeddedUrl; } } if (empty) { empty.hidden = true; } setPanelMessage("直播放流不可用,已回退到官网直播页"); return true; } function canPlayNativeHls(video, sourceUrl) { if (!(video instanceof HTMLVideoElement) || !sourceUrl) return false; const isLikelyHls = sourceUrl.includes(".m3u8") || sourceUrl.includes("mpegurl"); if (!isLikelyHls) return false; return video.canPlayType("application/vnd.apple.mpegurl") !== ""; } function tryStartPlayback(video) { if (!(video instanceof HTMLVideoElement)) return; video.autoplay = true; video.muted = true; const playPromise = video.play(); if (playPromise && typeof playPromise.catch === "function") { playPromise.catch((error) => { console.warn("TV 自动播放未成功:", error); setPanelMessage("已加载视频流,点击播放继续"); }); } } function attachVideoSource(video, source) { const sourceUrl = getVideoUrl(source); if (!(video instanceof HTMLVideoElement) || !sourceUrl) return; destroyHlsPlayer(); video.autoplay = true; video.muted = true; if (source.source_type === "hls") { if (canPlayNativeHls(video, sourceUrl)) { video.src = sourceUrl; video.load(); tryStartPlayback(video); return; } if (Hls.isSupported()) { hlsPlayer = new Hls({ enableWorker: true, lowLatencyMode: false, manifestLoadingTimeOut: 20000, levelLoadingTimeOut: 20000, fragLoadingTimeOut: 25000, fragLoadingMaxRetry: 3, fragLoadingRetryDelay: 1500, levelLoadingMaxRetry: 3, levelLoadingRetryDelay: 1500, manifestLoadingMaxRetry: 2, manifestLoadingRetryDelay: 1500, liveSyncDurationCount: 4, liveMaxLatencyDurationCount: 10, manifestLoadPolicy: { default: { maxTimeToFirstByteMs: 12000, maxLoadTimeMs: 20000, timeoutRetry: { ...HLS_RETRY_CONFIG, maxNumRetry: 2, }, errorRetry: { ...HLS_RETRY_CONFIG, maxNumRetry: 2, }, }, }, playlistLoadPolicy: { default: { maxTimeToFirstByteMs: 12000, maxLoadTimeMs: 20000, timeoutRetry: HLS_RETRY_CONFIG, errorRetry: HLS_RETRY_CONFIG, }, }, fragLoadPolicy: { default: { maxTimeToFirstByteMs: 12000, maxLoadTimeMs: 30000, timeoutRetry: HLS_RETRY_CONFIG, errorRetry: HLS_RETRY_CONFIG, }, }, }); hlsPlayer.loadSource(sourceUrl); hlsPlayer.attachMedia(video); hlsPlayer.on(Hls.Events.MANIFEST_PARSED, () => { hlsRecoveryAttempts = 0; setPanelMessage(TV_STATUS_MESSAGE.videoReady); tryStartPlayback(video); }); hlsPlayer.on(Hls.Events.ERROR, (_event, data) => { console.error("HLS 播放失败:", data); if (!data?.fatal) { if (data?.type === Hls.ErrorTypes.NETWORK_ERROR) { setPanelMessage("直播流网络波动,正在重试..."); return; } if (data?.type === Hls.ErrorTypes.MEDIA_ERROR) { setPanelMessage("直播流正在恢复..."); return; } } if (data?.fatal && hlsRecoveryAttempts < HLS_MAX_RECOVERY_ATTEMPTS) { hlsRecoveryAttempts += 1; if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { setPanelMessage(`直播流连接异常,正在重试 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`); hlsPlayer?.startLoad(); return; } if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { setPanelMessage(`直播流解码异常,正在恢复 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`); hlsPlayer?.recoverMediaError(); return; } } if (!showEmbeddedFallback(source) && !tryFallbackSource()) { setPanelMessage(TV_STATUS_MESSAGE.videoError); } }); return; } } video.src = sourceUrl; video.load(); tryStartPlayback(video); } function getExternalUrl(source) { return source?.homepage_url || source?.youtube_channel || source?.embed_url || source?.stream_url || ""; } function updateOpenButton(source) { const targetUrl = getExternalUrl(source); [ document.getElementById("mobile-tv-open-external"), document.getElementById("tv-open-external"), ].forEach((button) => { if (!(button instanceof HTMLButtonElement)) return; button.disabled = !targetUrl; button.onclick = targetUrl ? () => { window.open(targetUrl, "_blank", "noopener,noreferrer"); } : null; }); } function syncMobileOverviewSummary(source) { const headline = document.getElementById("mobile-tv-overview-headline"); const summary = document.getElementById("mobile-tv-overview-summary"); const tags = document.getElementById("mobile-tv-overview-tags"); if (headline instanceof HTMLElement) { headline.textContent = source?.name || "暂无可用频道"; } if (summary instanceof HTMLElement) { if (!source) { summary.textContent = "点击查看当前频道来源、目录和补充说明"; } else { const parts = [ source.provider, source.region, source.language, ].filter(Boolean); summary.textContent = parts.length ? parts.join(" · ") : "点击查看完整频道信息"; } } if (tags instanceof HTMLElement) { const tagValues = source ? [ { label: source.source_type || "频道", kind: "status" }, source.collector_source ? { label: `采集:${source.collector_source}`, kind: "" } : { label: "内置源", kind: "" }, source.region ? { label: source.region, kind: "" } : null, ].filter(Boolean).slice(0, 3) : [{ label: "待加载", kind: "status" }]; tags.replaceChildren( ...tagValues.map(({ label, kind }) => { const chip = document.createElement("span"); chip.className = `earth-mobile-tv-overview-tag${kind ? ` earth-mobile-tv-overview-tag--${kind}` : ""}`; chip.textContent = label; return chip; }), ); } } function findSourceById(sourceId) { return tvPayload?.sources?.find((source) => source.id === sourceId) || null; } function markSourceFailed(sourceId) { if (!sourceId) return; failedSourceIds.add(sourceId); renderSourceOptions(); if (!probeTimer) { probeTimer = setInterval(probeFailedSources, PROBE_INTERVAL_MS); } } function clearSourceFailed(sourceId) { if (!failedSourceIds.has(sourceId)) return; failedSourceIds.delete(sourceId); renderSourceOptions(); if (failedSourceIds.size === 0 && probeTimer) { clearInterval(probeTimer); probeTimer = null; } } async function probeFailedSources() { if (failedSourceIds.size === 0) { clearInterval(probeTimer); probeTimer = null; return; } for (const sourceId of [...failedSourceIds]) { const source = findSourceById(sourceId); if (!source) { failedSourceIds.delete(sourceId); continue; } const probeUrl = source.stream_url || source.embed_url; if (!probeUrl) continue; try { const resp = await fetch(probeUrl, { method: "HEAD", signal: AbortSignal.timeout(5000), }); if (resp.ok) clearSourceFailed(sourceId); } catch { // 仍然失效,保持标记 } } } function tryFallbackSource() { const fallback = tvPayload?.fallback_source; if (!fallback || fallback.id === currentSourceId) return false; markSourceFailed(currentSourceId); currentSourceId = fallback.id; const { select } = getElements(); if (select) select.value = currentSourceId; renderSource(fallback); return true; } function getCurrentSource() { return findSourceById(currentSourceId); } function setPanelMessage(message) { const { status } = getElements(); if (status) { status.textContent = message || TV_STATUS_MESSAGE.idle; } } function resetIframe(iframe) { if (!(iframe instanceof HTMLIFrameElement)) return; iframe.removeAttribute("src"); iframe.hidden = true; } function resetVideo(video) { if (!(video instanceof HTMLVideoElement)) return; video.removeAttribute("src"); video.hidden = true; video.load(); } function showEmptyState(empty, message) { if (!(empty instanceof HTMLElement)) return; empty.hidden = false; empty.textContent = message; } function hideEmptyState(empty) { if (empty instanceof HTMLElement) { empty.hidden = true; } } function renderVideoSource(video, iframe, source) { resetIframe(iframe); if (video instanceof HTMLVideoElement) { video.hidden = false; attachVideoSource(video, source); } } function renderEmbeddedSource(iframe, video, embeddedUrl) { destroyHlsPlayer(); resetVideo(video); if (iframe instanceof HTMLIFrameElement) { iframe.hidden = false; if (iframe.src !== embeddedUrl) { iframe.src = embeddedUrl; } } } function renderSourceOptions() { const { select } = getElements(); if (!select) return; const sources = tvPayload?.sources || []; const fragment = document.createDocumentFragment(); sources.forEach((source) => { const sourceOriginLabel = source.collector_source ? "[采集]" : "[内置]"; const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : ""; const failMark = failedSourceIds.has(source.id) ? " ⚠" : ""; const option = document.createElement("option"); option.value = source.id; option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`; fragment.appendChild(option); }); select.replaceChildren(fragment); if (currentSourceId) { select.value = currentSourceId; } } function renderSource(source) { const { title, meta, catalog, notes, iframe, video, empty } = getElements(); const embeddedUrl = getEmbeddedUrl(source); const videoUrl = getVideoUrl(source); const externalUrl = getExternalUrl(source); const isVideo = Boolean(videoUrl); const isExternalOnly = Boolean(source) && !embeddedUrl && !videoUrl && Boolean(externalUrl); if (title) { title.textContent = source?.name || "暂无可用频道"; } if (meta) { meta.textContent = source ? `${source.provider} · ${source.region} · ${source.language} · ${source.source_type}` : "当前未配置可播放新闻直播源"; } if (catalog) { const sourceCount = tvPayload?.source_count || tvPayload?.sources?.length || 0; const latestUpdatedAt = tvPayload?.latest_updated_at || tvPayload?.generated_at || ""; const latestLabel = latestUpdatedAt ? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}` : "尚未同步"; const sourceOriginLabel = source?.collector_source ? `采集源 ${source.collector_source}` : source ? "内置源" : ""; catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${sourceOriginLabel ? ` · ${sourceOriginLabel}` : ""}`; } if (notes) { notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。"; } syncMobileOverviewSummary(source); if (!source || (!embeddedUrl && !videoUrl)) { destroyHlsPlayer(); resetIframe(iframe); resetVideo(video); showEmptyState( empty, isExternalOnly ? "当前频道仅支持跳转官网或外部播放器打开。" : "暂无可播放直播源,请先在系统配置中添加频道。", ); setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty); updateOpenButton(source); return; } if (isVideo && videoUrl) { renderVideoSource(video, iframe, source); } else { renderEmbeddedSource(iframe, video, embeddedUrl); } hideEmptyState(empty); setPanelMessage( source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道", ); updateOpenButton(source); autoExpandMeta(); } function resolveInitialSourceId() { if (findSourceById(currentSourceId)) { return currentSourceId; } return tvPayload?.selected_source?.id || tvPayload?.default_source_id || tvPayload?.sources?.[0]?.id || ""; } function renderPanel() { currentSourceId = resolveInitialSourceId(); renderSourceOptions(); renderSource(findSourceById(currentSourceId)); } export async function loadTVStreams() { const response = await fetch(TV_STREAMS_API, { headers: { Accept: "application/json", }, }); if (!response.ok) { throw new Error(`Failed to load TV streams: ${response.status}`); } tvPayload = await response.json(); return tvPayload; } export async function refreshTVPanel() { if (refreshPromise) { return refreshPromise; } setPanelMessage(TV_STATUS_MESSAGE.syncing); refreshPromise = (async () => { try { await loadTVStreams(); renderPanel(); } catch (error) { console.error("加载电视直播源失败:", error); setPanelMessage(TV_STATUS_MESSAGE.loadFailed); renderSource(findSourceById(currentSourceId)); } finally { refreshPromise = null; } })(); return refreshPromise; } export async function ensureTVPanelReady() { if (!initialized) { initTVPanel(); } if (!tvPayload) { await refreshTVPanel(); return; } renderPanel(); } export function initTVPanel() { if (initialized) return; initialized = true; const { select, refreshBtn, iframe, video, toggleBtn, panel, metaToggle, liveTabBtn, newsTabBtn, } = getElements(); const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar"); if (panel && metaToggle) { mediaPanel = createHUDPanel({ panel, header: ".hud-panel__header", body: "#tv-meta-wrap", collapseBtn: metaToggle, bodyCollapsedClass: "is-collapsed", preferredDirection: "up", expandLabel: "展开新闻直播信息", collapseLabel: "折叠新闻直播信息", }); } mobileMetaCollapsed = true; syncMetaToggleState(isMetaCollapsed()); setMetaCollapsed(isMetaCollapsed()); updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden")); syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden")); toggleBtn?.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); const currentlyVisible = mediaPanel?.isVisible() ?? false; if (!currentlyVisible) { setPanelVisible(true); if (activeTab === "live") { await ensureTVPanelReady(); showStatusMessage("新闻直播窗口已打开", "info"); } else { showStatusMessage("态势新闻窗口已打开", "info"); } return; } const nextTab = activeTab === "live" ? "news" : "live"; setActiveTab(nextTab); if (nextTab === "live") { await ensureTVPanelReady(); showStatusMessage("已切换到新闻直播", "info"); return; } showStatusMessage("已切换到态势新闻", "info"); }); [select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")] .filter((element, index, array) => element && array.indexOf(element) === index) .forEach((selectEl) => { selectEl?.addEventListener("change", (event) => { const target = event.currentTarget; if (!(target instanceof HTMLSelectElement)) return; currentSourceId = target.value; renderSource(findSourceById(currentSourceId)); }); }); [metaToggle, document.getElementById("tv-meta-toggle")] .filter((element, index, array) => element && array.indexOf(element) === index) .forEach((toggleEl) => { toggleEl?.addEventListener("click", () => { clearTimeout(metaAutoCollapseTimer); const isNowCollapsed = !isMetaCollapsed(); setMetaCollapsed(isNowCollapsed); }); }); const toggleMobileMeta = (event) => { const interactiveTarget = event.target instanceof Element ? event.target.closest("button, a, select, input, textarea, video, iframe") : null; if (interactiveTarget) return; clearTimeout(metaAutoCollapseTimer); const isNowCollapsed = !isMetaCollapsed(); setMetaCollapsed(isNowCollapsed); }; mobileOverviewBar?.addEventListener("click", toggleMobileMeta); mobileOverviewBar?.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); toggleMobileMeta(event); }); [refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")] .filter((element, index, array) => element && array.indexOf(element) === index) .forEach((refreshEl) => { refreshEl?.addEventListener("click", () => { refreshTVPanel(); }); }); liveTabBtn?.addEventListener("click", () => { setActiveTab("live"); }); newsTabBtn?.addEventListener("click", () => { setActiveTab("news"); }); [iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")] .filter((element, index, array) => element && array.indexOf(element) === index) .forEach((iframeEl) => { iframeEl?.addEventListener("load", () => { if (iframeEl.hidden) return; clearSourceFailed(currentSourceId); setPanelMessage(TV_STATUS_MESSAGE.iframeReady); }); }); [video, document.getElementById("mobile-tv-video"), document.getElementById("tv-video")] .filter((element, index, array) => element && array.indexOf(element) === index) .forEach((videoEl) => { videoEl?.addEventListener("loadedmetadata", () => { if (videoEl.hidden) return; clearSourceFailed(currentSourceId); setPanelMessage(TV_STATUS_MESSAGE.videoReady); }); videoEl?.addEventListener("error", () => { const currentSource = getCurrentSource(); if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) { setPanelMessage(TV_STATUS_MESSAGE.videoError); } }); }); setupResizeHandle(); syncPanelActiveTab("live"); syncNewsDefaultMaxHeight(); updateTabButtonState(liveTabBtn, true); updateTabButtonState(newsTabBtn, false); captureTabState("live"); window.addEventListener("resize", syncNewsDefaultMaxHeight); }