Files
planet/frontend/public/earth/js/tv.js
2026-04-20 15:14:53 +08:00

958 lines
28 KiB
JavaScript

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 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() {
return {
// Outer media shell node.
panel: document.getElementById("media-panel"),
toggleBtn: document.getElementById("toggle-tv"),
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"),
metaWrap: document.getElementById("tv-meta-wrap"),
metaToggle: document.getElementById("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 setMetaCollapsed(collapsed) {
mediaPanel?.setCollapsed(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() {
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 = "";
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 active = visible && activeTab === "live";
toggleBtn.classList.toggle("active", active);
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = active ? "关闭新闻直播" : "打开新闻直播";
}
}
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);
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.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;
animateTabReform(() => {
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");
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: nextTab },
}));
});
}
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 { 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 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 defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
const option = document.createElement("option");
option.value = source.id;
option.textContent = `${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 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,
metaToggle,
liveTabBtn,
newsTabBtn,
} = getElements();
if (panel && metaToggle) {
mediaPanel = createHUDPanel({
panel,
header: ".hud-panel__header",
body: "#tv-meta-wrap",
collapseBtn: metaToggle,
bodyCollapsedClass: "is-collapsed",
preferredDirection: "up",
expandLabel: "展开新闻直播信息",
collapseLabel: "折叠新闻直播信息",
});
}
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);
setActiveTab("live");
await ensureTVPanelReady();
showStatusMessage("新闻直播窗口已打开", "info");
return;
}
if (activeTab !== "live") {
setActiveTab("live");
showStatusMessage("已切换到新闻直播", "info");
return;
}
setPanelVisible(false);
showStatusMessage("新闻直播窗口已关闭", "info");
});
select?.addEventListener("change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) return;
currentSourceId = target.value;
renderSource(findSourceById(currentSourceId));
});
metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
setMetaCollapsed(isNowCollapsed);
});
refreshBtn?.addEventListener("click", () => {
refreshTVPanel();
});
liveTabBtn?.addEventListener("click", () => {
setActiveTab("live");
});
newsTabBtn?.addEventListener("click", () => {
setActiveTab("news");
});
iframe?.addEventListener("load", () => {
if (iframe.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
video?.addEventListener("loadedmetadata", () => {
if (video.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
video?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
setupResizeHandle();
syncPanelActiveTab("live");
syncNewsDefaultMaxHeight();
setActiveTab("live");
window.addEventListener("resize", syncNewsDefaultMaxHeight);
}