import Hls from "hls.js"; import { showStatusMessage } from "./ui.js"; 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; const META_AUTO_COLLAPSE_DELAY = 2500; const HLS_MAX_RECOVERY_ATTEMPTS = 3; const HLS_RETRY_CONFIG = { maxNumRetry: 4, retryDelayMs: 1500, maxRetryDelayMs: 8000, backoff: "exponential", }; function getElements() { return { panel: document.getElementById("tv-panel"), toggleBtn: document.getElementById("toggle-tv"), resizeHandle: null, select: document.getElementById("tv-source-select"), title: document.getElementById("tv-source-title"), meta: document.getElementById("tv-source-meta"), catalog: document.getElementById("tv-source-catalog"), status: document.getElementById("tv-source-status"), notes: document.getElementById("tv-source-notes"), iframe: document.getElementById("tv-iframe"), video: document.getElementById("tv-video"), empty: document.getElementById("tv-empty-state"), refreshBtn: document.getElementById("tv-refresh"), openBtn: document.getElementById("tv-open-external"), metaPanel: document.getElementById("tv-panel-meta"), metaWrap: document.getElementById("tv-meta-wrap"), metaToggle: document.getElementById("tv-meta-toggle"), }; } function setMetaCollapsed(collapsed) { const { metaWrap, metaToggle, panel } = getElements(); if (!metaWrap) return; const isDragged = panel?.dataset.dragged === "true" && panel.style.top; if (isDragged) { // Top-anchored panel: toggle instantly and compensate top so the player // (panel bottom) stays visually fixed. const bottomBefore = panel.getBoundingClientRect().bottom; metaWrap.style.transition = "none"; metaWrap.classList.toggle("is-collapsed", collapsed); metaToggle?.classList.toggle("is-collapsed", collapsed); // Force synchronous reflow to get updated panel height void panel.offsetHeight; const delta = panel.getBoundingClientRect().bottom - bottomBefore; if (delta !== 0) { panel.style.top = `${parseFloat(panel.style.top) - delta}px`; } // Restore CSS transition after this paint requestAnimationFrame(() => { metaWrap.style.transition = ""; }); } else { metaWrap.classList.toggle("is-collapsed", collapsed); metaToggle?.classList.toggle("is-collapsed", collapsed); } } function autoExpandMeta() { 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 getHudScale() { const scale = Number.parseFloat( getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), ); return Number.isFinite(scale) && scale > 0 ? scale : 1; } function setupResizeHandle() { const { panel } = getElements(); const container = document.getElementById("container"); if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return; let resizing = false; let activeEdge = ""; let startX = 0; let startY = 0; let startWidth = 0; let startHeight = 0; let startLeft = 0; let startTop = 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(360 * hudScale)); const minHeight = Math.max(260, Math.round(340 * hudScale)); const dx = event.clientX - startX; const dy = event.clientY - startY; if (activeEdge.includes("r")) { const maxW = containerRect.width - startLeft - 12; panel.style.width = `${Math.min(maxW, Math.max(minWidth, startWidth + dx))}px`; } if (activeEdge.includes("l")) { const newW = Math.max(minWidth, startWidth - dx); panel.style.width = `${newW}px`; panel.style.left = `${Math.max(0, startLeft + startWidth - newW)}px`; } if (activeEdge.includes("b")) { const maxH = containerRect.height - startTop - 12; panel.style.minHeight = `${Math.min(maxH, Math.max(minHeight, startHeight + 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; startX = event.clientX; startY = event.clientY; clearPanelPositioningForResize(panel); const rect = panel.getBoundingClientRect(); const cRect = container.getBoundingClientRect(); startWidth = rect.width; startHeight = rect.height; startLeft = rect.left - cRect.left; startTop = rect.top - cRect.top; 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; toggleBtn.classList.toggle("active", visible); const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip"); if (tooltip) { tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播"; } } function syncSettingsToggle(visible) { const input = document.querySelector('[data-settings-panel="tv-panel"]'); if (input instanceof HTMLInputElement) { input.checked = visible; } } function setPanelVisible(visible) { const { panel } = getElements(); if (!panel) return; panel.classList.toggle("hud-panel-hidden", !visible); updateToggleButton(visible); syncSettingsToggle(visible); } 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)) { 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 { openBtn } = getElements(); if (!openBtn) return; const targetUrl = getExternalUrl(source); openBtn.disabled = !targetUrl; openBtn.onclick = targetUrl ? () => { window.open(targetUrl, "_blank", "noopener,noreferrer"); } : null; } function findSourceById(sourceId) { return tvPayload?.sources?.find((source) => source.id === sourceId) || null; } 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 marker = source.id === tvPayload?.default_source_id ? " · 默认" : ""; const option = document.createElement("option"); option.value = source.id; option.textContent = `${source.name}${marker}`; 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 collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : ""; catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`; } if (notes) { notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。"; } 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 } = getElements(); updateToggleButton(!panel?.classList.contains("hud-panel-hidden")); syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden")); toggleBtn?.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true; setPanelVisible(nextVisible); if (nextVisible) { await ensureTVPanelReady(); showStatusMessage("新闻直播窗口已打开", "info"); } else { showStatusMessage("新闻直播窗口已关闭", "info"); } }); select?.addEventListener("change", (event) => { const target = event.currentTarget; if (!(target instanceof HTMLSelectElement)) return; currentSourceId = target.value; renderSource(findSourceById(currentSourceId)); }); const { metaToggle } = getElements(); metaToggle?.addEventListener("click", () => { clearTimeout(metaAutoCollapseTimer); const isNowCollapsed = !metaToggle.classList.contains("is-collapsed"); setMetaCollapsed(isNowCollapsed); }); refreshBtn?.addEventListener("click", () => { refreshTVPanel(); }); iframe?.addEventListener("load", () => { if (iframe.hidden) return; setPanelMessage(TV_STATUS_MESSAGE.iframeReady); }); video?.addEventListener("loadedmetadata", () => { if (video.hidden) return; setPanelMessage(TV_STATUS_MESSAGE.videoReady); }); video?.addEventListener("error", () => { const currentSource = getCurrentSource(); if (!showEmbeddedFallback(currentSource)) { setPanelMessage(TV_STATUS_MESSAGE.videoError); } }); setupResizeHandle(); }