1137 lines
36 KiB
JavaScript
1137 lines
36 KiB
JavaScript
import Hls from "hls.js";
|
||
import { showStatusMessage } from "./ui.js";
|
||
import { createHUDPanel } from "./hud-panels.js";
|
||
import {
|
||
earthMessage,
|
||
formatLocaleDateTime,
|
||
getEarthLocale,
|
||
hasCjkText,
|
||
onEarthLocaleChange,
|
||
translateText,
|
||
} from "./i18n.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",
|
||
]);
|
||
|
||
const TV_SOURCE_TYPE_LABELS = {
|
||
hls: "HLS",
|
||
video: "Video",
|
||
iframe: "Web",
|
||
external: "External",
|
||
youtube: "YouTube",
|
||
};
|
||
|
||
function isEnglishLocale() {
|
||
return getEarthLocale() === "en-US";
|
||
}
|
||
|
||
function titleCaseIdentifier(value) {
|
||
return String(value || "")
|
||
.replace(/[-_]+/g, " ")
|
||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||
.trim();
|
||
}
|
||
|
||
function safeTVText(value, fallback = "") {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return fallback;
|
||
if (isEnglishLocale() && hasCjkText(text)) return fallback;
|
||
return text;
|
||
}
|
||
|
||
function getTVSourceName(source) {
|
||
if (!source) return translateText("暂无可用频道");
|
||
return safeTVText(
|
||
source.name,
|
||
titleCaseIdentifier(source.id) || "Live Channel",
|
||
);
|
||
}
|
||
|
||
function getTVSourceTypeLabel(sourceType) {
|
||
const normalized = String(sourceType || "").trim().toLowerCase();
|
||
return TV_SOURCE_TYPE_LABELS[normalized] || safeTVText(sourceType, translateText("频道"));
|
||
}
|
||
|
||
function getTVSourceField(value, fallback = "") {
|
||
return safeTVText(value, fallback);
|
||
}
|
||
|
||
function getTVCollectorLabel(value) {
|
||
const collector = getTVSourceField(value, "Collector");
|
||
return `${translateText("采集")}: ${collector}`;
|
||
}
|
||
|
||
function getTVNotes(source) {
|
||
if (!source?.notes) return translateText("支持后台配置默认源与采集器补充源。");
|
||
return safeTVText(source.notes, translateText("配置于控制台"));
|
||
}
|
||
|
||
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",
|
||
translateText(collapsed ? "展开新闻直播内容" : "折叠新闻直播内容"),
|
||
);
|
||
desktopToggle.title = translateText(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 = translateText(title);
|
||
toggleBtn.setAttribute("aria-label", translateText(title));
|
||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||
if (tooltip) {
|
||
tooltip.textContent = translateText(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 = getTVSourceName(source);
|
||
}
|
||
if (summary instanceof HTMLElement) {
|
||
if (!source) {
|
||
summary.textContent = translateText("点击查看当前频道来源、目录和补充说明");
|
||
} else {
|
||
const parts = [
|
||
getTVSourceField(source.provider),
|
||
getTVSourceField(source.region),
|
||
getTVSourceField(source.language),
|
||
].filter(Boolean);
|
||
summary.textContent = parts.length
|
||
? parts.join(" · ")
|
||
: translateText("点击查看完整频道信息");
|
||
}
|
||
}
|
||
if (tags instanceof HTMLElement) {
|
||
const tagValues = source
|
||
? [
|
||
{ label: getTVSourceTypeLabel(source.source_type), kind: "status" },
|
||
source.collector_source ? { label: getTVCollectorLabel(source.collector_source), kind: "" } : { label: translateText("内置源"), kind: "" },
|
||
source.region ? { label: getTVSourceField(source.region, "Global"), kind: "" } : null,
|
||
].filter(Boolean).slice(0, 3)
|
||
: [{ label: translateText("待加载"), 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 = translateText(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 = translateText(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 ? ` · ${translateText("默认")}` : "";
|
||
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
||
const option = document.createElement("option");
|
||
option.value = source.id;
|
||
option.textContent = `${getTVSourceName(source)}${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 = getTVSourceName(source);
|
||
}
|
||
if (origin instanceof HTMLElement) {
|
||
origin.textContent = source?.collector_source ? translateText("采集") : source ? translateText("内置") : translateText("待加载");
|
||
origin.title = source?.collector_source ? `${translateText("采集源")}: ${getTVSourceField(source.collector_source, "Collector")}` : source ? translateText("内置源") : translateText("待加载");
|
||
}
|
||
if (meta) {
|
||
const metaParts = source
|
||
? [
|
||
getTVSourceField(source.provider),
|
||
getTVSourceField(source.region),
|
||
getTVSourceField(source.language),
|
||
getTVSourceTypeLabel(source.source_type),
|
||
].filter(Boolean)
|
||
: [];
|
||
meta.textContent = metaParts.length
|
||
? metaParts.join(" · ")
|
||
: translateText("当前未配置可播放新闻直播源");
|
||
}
|
||
if (catalog) {
|
||
const sourceCount = tvPayload?.source_count || tvPayload?.sources?.length || 0;
|
||
const latestUpdatedAt = tvPayload?.latest_updated_at || tvPayload?.generated_at || "";
|
||
const latestLabel = latestUpdatedAt
|
||
? `${translateText("最近同步")} ${formatLocaleDateTime(latestUpdatedAt)}`
|
||
: translateText("尚未同步");
|
||
catalog.textContent = isEnglishLocale()
|
||
? `${sourceCount} channels · ${latestLabel}`
|
||
: `共 ${sourceCount} 个频道 · ${latestLabel}`;
|
||
}
|
||
if (notes) {
|
||
notes.textContent = getTVNotes(source);
|
||
}
|
||
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: translateText("展开新闻直播信息"),
|
||
collapseLabel: translateText("折叠新闻直播信息"),
|
||
});
|
||
}
|
||
|
||
mobileMetaCollapsed = true;
|
||
syncMetaToggleState(isMetaCollapsed());
|
||
setMetaCollapsed(isMetaCollapsed());
|
||
|
||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||
onEarthLocaleChange(() => {
|
||
syncMetaToggleState(isMetaCollapsed());
|
||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||
if (tvPayload) renderPanel();
|
||
else renderSource(null);
|
||
});
|
||
|
||
toggleBtn?.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const currentlyVisible = mediaPanel?.isVisible() ?? false;
|
||
if (!currentlyVisible) {
|
||
setPanelVisible(true);
|
||
await ensureTVPanelReady();
|
||
showStatusMessage(earthMessage("status.newsPanel", { open: true }), "info");
|
||
return;
|
||
}
|
||
|
||
setPanelVisible(false);
|
||
showStatusMessage(earthMessage("status.newsPanel", { open: false }), "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");
|
||
}
|