Files
planet/frontend/public/earth/js/tv.js
rayd1o 7cd29cf9c0 release: bump version to 0.27.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:37:45 +08:00

618 lines
18 KiB
JavaScript

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;
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: document.getElementById("tv-resize-handle"),
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"),
};
}
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, resizeHandle } = getElements();
const container = document.getElementById("container");
if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) {
return;
}
let resizing = false;
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
const stopResize = () => {
resizing = false;
panel.classList.remove("is-resizing");
document.body.style.userSelect = "";
};
resizeHandle.addEventListener("pointerdown", (event) => {
if (document.getElementById("container")?.classList.contains("layout-expanded")) {
return;
}
event.preventDefault();
event.stopPropagation();
resizing = true;
startX = event.clientX;
startY = event.clientY;
clearPanelPositioningForResize(panel);
const rect = panel.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
panel.classList.add("is-resizing");
document.body.style.userSelect = "none";
resizeHandle.setPointerCapture?.(event.pointerId);
});
resizeHandle.addEventListener("pointermove", (event) => {
if (!resizing) return;
const containerRect = container.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const currentLeft = panelRect.left - containerRect.left;
const currentTop = panelRect.top - containerRect.top;
const hudScale = getHudScale();
const minWidth = Math.max(320, Math.round(360 * hudScale));
const minHeight = Math.max(260, Math.round(340 * hudScale));
const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12);
const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12);
const nextWidth = Math.min(
maxWidth,
Math.max(minWidth, startWidth + (event.clientX - startX)),
);
const nextHeight = Math.min(
maxHeight,
Math.max(minHeight, startHeight + (event.clientY - startY)),
);
panel.style.width = `${nextWidth}px`;
panel.style.minHeight = `${nextHeight}px`;
});
resizeHandle.addEventListener("pointerup", stopResize);
resizeHandle.addEventListener("pointercancel", stopResize);
resizeHandle.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);
}
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));
});
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();
}