Files
planet/frontend/public/earth/js/tv.js
linkong acbbfdf9e2
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.69.0
2026-06-03 17:27:00 +08:00

1060 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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;
const failedSourceIds = new Set();
let probeTimer = null;
let mobileMetaCollapsed = false;
const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
const PANEL_RESIZE_MARGIN_PX = 12;
const TV_PANEL_MIN_WIDTH_PX = 360;
const TV_PANEL_MIN_HEIGHT_PX = 340;
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = {
maxNumRetry: 1,
retryDelayMs: 1200,
maxRetryDelayMs: 3000,
backoff: "exponential",
};
const HLS_NON_PLAYBACK_ERROR_DETAILS = new Set([
"subtitleTrackLoadError",
"subtitleTrackParsingError",
"subtitleTrackSwitchError",
"audioTrackLoadError",
"audioTrackSwitchError",
"keyLoadError",
]);
const HLS_SOURCE_FAILURE_DETAILS = new Set([
"manifestLoadError",
"manifestLoadTimeOut",
"levelLoadError",
"levelLoadTimeOut",
"fragLoadError",
"fragLoadTimeOut",
]);
function isVideoActuallyPlaying(video) {
return (
video instanceof HTMLVideoElement
&& !video.paused
&& !video.ended
&& video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
}
function getHlsErrorMessage(data) {
const details = data?.details || "";
const response = data?.response || {};
const status = response?.code || response?.status;
const url = data?.url || response?.url || "";
if (details === "manifestLoadError" || details === "manifestLoadTimeOut") {
return status
? `HLS 主播放列表加载失败HTTP ${status}`
: "HLS 主播放列表加载失败";
}
if (details === "levelLoadError" || details === "levelLoadTimeOut") {
return status
? `HLS 清晰度播放列表加载失败HTTP ${status}`
: "HLS 清晰度播放列表加载失败";
}
if (details === "fragLoadError" || details === "fragLoadTimeOut") {
return status
? `HLS 分片加载失败HTTP ${status}`
: "HLS 分片加载失败";
}
if (details === "bufferStalledError") return "直播流缓冲停滞,正在等待数据";
if (details === "bufferAppendError") return "直播流缓冲写入失败";
if (details === "manifestParsingError") return "HLS 播放列表格式无法解析";
if (details === "fragParsingError") return "HLS 分片格式无法解析";
if (url) return `HLS 资源加载失败:${url}`;
return "HLS 播放流不可用";
}
function logHlsDiagnostic(data, source) {
const payload = {
source_id: source?.id,
source_name: source?.name,
type: data?.type,
details: data?.details,
fatal: Boolean(data?.fatal),
url: data?.url || data?.response?.url,
status: data?.response?.code || data?.response?.status,
};
if (data?.fatal) {
console.error("HLS 播放失败:", payload, data);
} else {
console.debug("HLS 非致命事件:", payload, data);
}
}
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"),
origin: document.getElementById("tv-source-origin"),
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"),
// Inner tab panes.
livePane: document.getElementById("tv-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 = "live") {
const { panel } = getElements();
if (panel instanceof HTMLElement) {
panel.dataset.activeTab = tab;
}
}
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 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");
toggleBtn.classList.toggle("active", visible);
if (icon) {
icon.textContent = "live_tv";
}
const title = visible ? "关闭 Live 新闻" : "打开 Live 新闻";
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, { persist = true } = {}) {
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, persist },
}));
}
export function setTVPanelVisible(visible, options = {}) {
setPanelVisible(visible, options);
}
function setActiveTab() {
syncPanelActiveTab("live");
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: "live" },
}));
}
export function openTVPanelTab() {
setPanelVisible(true);
setActiveTab("live");
}
export function setActiveTVTab(tab = "live") {
setActiveTab(tab);
}
export function isTVPanelVisible() {
return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
}
export function getActiveTVTab() {
return "live";
}
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;
let hlsNetworkErrorCount = 0;
let hlsHardFailureHandled = false;
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,
enableWebVTT: false,
subtitleDisplay: false,
lowLatencyMode: false,
manifestLoadingTimeOut: 20000,
levelLoadingTimeOut: 20000,
fragLoadingTimeOut: 25000,
fragLoadingMaxRetry: 1,
fragLoadingRetryDelay: 1200,
levelLoadingMaxRetry: 1,
levelLoadingRetryDelay: 1200,
manifestLoadingMaxRetry: 1,
manifestLoadingRetryDelay: 1200,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 10,
manifestLoadPolicy: {
default: {
maxTimeToFirstByteMs: 12000,
maxLoadTimeMs: 20000,
timeoutRetry: {
...HLS_RETRY_CONFIG,
maxNumRetry: 1,
},
errorRetry: {
...HLS_RETRY_CONFIG,
maxNumRetry: 1,
},
},
},
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) => {
logHlsDiagnostic(data, source);
const status = Number(data?.response?.code || data?.response?.status || 0);
const sourceFailure = HLS_SOURCE_FAILURE_DETAILS.has(data?.details);
if (sourceFailure && (status >= 500 || data?.details === "manifestLoadError")) {
hlsNetworkErrorCount += 1;
}
if (!hlsHardFailureHandled && !isVideoActuallyPlaying(video) && sourceFailure && hlsNetworkErrorCount >= 2) {
hlsHardFailureHandled = true;
const reasonMessage = getHlsErrorMessage(data);
hlsPlayer?.stopLoad();
if (!showEmbeddedFallback(source, reasonMessage) && !tryFallbackSource()) {
setPanelMessage(reasonMessage);
}
return;
}
if (
!data?.fatal
&& HLS_NON_PLAYBACK_ERROR_DETAILS.has(data?.details)
&& isVideoActuallyPlaying(video)
) {
return;
}
if (!data?.fatal) {
if (data?.type === Hls.ErrorTypes.NETWORK_ERROR) {
setPanelMessage(isVideoActuallyPlaying(video) ? TV_STATUS_MESSAGE.videoReady : "直播流网络波动,正在重试...");
return;
}
if (data?.type === Hls.ErrorTypes.MEDIA_ERROR) {
setPanelMessage(isVideoActuallyPlaying(video) ? TV_STATUS_MESSAGE.videoReady : "直播流正在恢复...");
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;
}
}
const reasonMessage = getHlsErrorMessage(data);
if (!showEmbeddedFallback(source, reasonMessage) && !tryFallbackSource()) {
setPanelMessage(reasonMessage);
}
});
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;
if (status.id === "tv-source-status") {
const normalized = message || TV_STATUS_MESSAGE.idle;
status.classList.toggle(
"tv-panel-tag--error",
normalized === TV_STATUS_MESSAGE.videoError || normalized === TV_STATUS_MESSAGE.loadFailed,
);
status.classList.toggle(
"tv-panel-tag--warning",
normalized === TV_STATUS_MESSAGE.syncing ||
normalized === TV_STATUS_MESSAGE.externalOnly ||
normalized.includes("重试") ||
normalized.includes("恢复") ||
normalized.includes("回退"),
);
}
}
}
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, origin, 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 (origin instanceof HTMLElement) {
origin.textContent = source?.collector_source ? "采集" : source ? "内置" : "待加载";
origin.title = source?.collector_source ? `采集源:${source.collector_source}` : source ? "内置源" : "待加载";
}
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 })}`
: "尚未同步";
catalog.textContent = `${sourceCount} 个频道 · ${latestLabel}`;
}
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("直播加载中");
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,
} = 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);
await ensureTVPanelReady();
showStatusMessage("Live 新闻窗口已打开", "info");
return;
}
setPanelVisible(false);
showStatusMessage("Live 新闻窗口已关闭", "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();
});
});
[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");
}