958 lines
28 KiB
JavaScript
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);
|
|
}
|