631 lines
16 KiB
JavaScript
631 lines
16 KiB
JavaScript
class StreamWatcher {
|
|
constructor() {
|
|
this.streamId = this.getStreamId();
|
|
this.srsUrl = this.getSrsUrl();
|
|
this.streamData = null;
|
|
this.refreshInterval = null;
|
|
this.isFullscreen = false;
|
|
this.player = null;
|
|
this.isDestroyed = false;
|
|
this.retryCount = 0;
|
|
this.maxRetries = 3;
|
|
this.userInteracted = false;
|
|
this.pendingPlay = false;
|
|
|
|
this.initializeElements();
|
|
this.bindEvents();
|
|
this.setupUserInteractionDetection();
|
|
this.loadStreamData();
|
|
this.startAutoRefresh();
|
|
}
|
|
|
|
getStreamId() {
|
|
const metaTag = document.querySelector('meta[name="stream-id"]');
|
|
if (!metaTag) {
|
|
throw new Error("Stream ID not found in meta tags");
|
|
}
|
|
return metaTag.content;
|
|
}
|
|
|
|
getSrsUrl() {
|
|
const metaTag = document.querySelector('meta[name="srs-url"]');
|
|
if (!metaTag) {
|
|
throw new Error("SRS URL not found in meta tags");
|
|
}
|
|
return metaTag.content;
|
|
}
|
|
|
|
initializeElements() {
|
|
this.elements = {
|
|
streamTitle: document.getElementById("streamTitle"),
|
|
loadingState: document.getElementById("loadingState"),
|
|
errorState: document.getElementById("errorState"),
|
|
errorMessage: document.getElementById("errorMessage"),
|
|
retryBtn: document.getElementById("retryBtn"),
|
|
videoPlayer: document.getElementById("videoPlayer"),
|
|
streamStatus: document.getElementById("streamStatus"),
|
|
streamStats: document.getElementById("streamStats"),
|
|
viewerCount: document.getElementById("viewerCount"),
|
|
bitrate: document.getElementById("bitrate"),
|
|
quality: document.getElementById("quality"),
|
|
streamId: document.getElementById("streamId"),
|
|
streamApp: document.getElementById("streamApp"),
|
|
streamDuration: document.getElementById("streamDuration"),
|
|
videoCodec: document.getElementById("videoCodec"),
|
|
audioCodec: document.getElementById("audioCodec"),
|
|
resolution: document.getElementById("resolution"),
|
|
rtmpUrl: document.getElementById("rtmpUrl"),
|
|
hlsUrl: document.getElementById("hlsUrl"),
|
|
flvUrl: document.getElementById("flvUrl"),
|
|
webrtcUrl: document.getElementById("webrtcUrl"),
|
|
videoContainer: document.querySelector(".video-container"),
|
|
};
|
|
}
|
|
|
|
setupUserInteractionDetection() {
|
|
const events = ["click", "touchstart", "keydown", "scroll"];
|
|
const handleInteraction = () => {
|
|
this.userInteracted = true;
|
|
if (this.pendingPlay && this.elements.videoPlayer) {
|
|
this.attemptAutoplay();
|
|
}
|
|
|
|
for (const event of events) {
|
|
document.removeEventListener(event, handleInteraction, {
|
|
once: true,
|
|
passive: true,
|
|
capture: true,
|
|
});
|
|
}
|
|
};
|
|
|
|
for (const event of events) {
|
|
document.addEventListener(event, handleInteraction, {
|
|
once: true,
|
|
passive: true,
|
|
capture: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
bindEvents() {
|
|
this.elements.retryBtn.addEventListener("click", () => {
|
|
this.retryCount = 0;
|
|
this.userInteracted = true;
|
|
this.loadStreamData();
|
|
});
|
|
|
|
this.elements.videoContainer.addEventListener("click", () => {
|
|
this.userInteracted = true;
|
|
if (this.elements.videoPlayer?.paused) {
|
|
this.attemptAutoplay();
|
|
}
|
|
});
|
|
|
|
const urlElements = [
|
|
this.elements.rtmpUrl,
|
|
this.elements.hlsUrl,
|
|
this.elements.flvUrl,
|
|
this.elements.webrtcUrl,
|
|
];
|
|
|
|
for (const element of urlElements) {
|
|
element.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const url = element.textContent.trim();
|
|
if (url !== "-") {
|
|
navigator.clipboard
|
|
.writeText(url)
|
|
.then(() => {
|
|
element.style.background = "rgba(34, 197, 94, 0.2)";
|
|
setTimeout(() => {
|
|
element.style.background = "";
|
|
}, 1000);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) {
|
|
this.stopAutoRefresh();
|
|
} else {
|
|
this.startAutoRefresh();
|
|
setTimeout(() => this.loadStreamData(), 100);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("fullscreenchange", () => {
|
|
this.isFullscreen = !!document.fullscreenElement;
|
|
});
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
this.cleanup();
|
|
});
|
|
}
|
|
|
|
async loadStreamData() {
|
|
const wasPlaying =
|
|
this.elements.videoPlayer && !this.elements.videoPlayer.paused;
|
|
|
|
if (!wasPlaying) {
|
|
this.showLoading();
|
|
}
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
|
|
|
const response = await fetch("/api/streams", {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Cache-Control": "no-cache",
|
|
},
|
|
credentials: "same-origin",
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || "Failed to load streams");
|
|
}
|
|
|
|
const streams = result.data.streams || [];
|
|
const newStreamData = streams.find(
|
|
(stream) =>
|
|
stream.name === this.streamId || stream.id === this.streamId,
|
|
);
|
|
|
|
if (!newStreamData) {
|
|
this.elements.streamTitle.textContent = `No stream found for: ${this.streamId}`;
|
|
throw new Error("Stream not found");
|
|
}
|
|
|
|
const wasOnline = this.streamData?.publish?.active === true;
|
|
const isOnline = newStreamData.publish?.active === true;
|
|
|
|
this.streamData = newStreamData;
|
|
this.updateStreamInfo();
|
|
|
|
if (wasOnline !== isOnline || !this.player) {
|
|
await this.setupVideoPlayer();
|
|
}
|
|
|
|
this.retryCount = 0;
|
|
} catch (error) {
|
|
if (error.name === "AbortError") {
|
|
this.showError("Request timeout - please check your connection");
|
|
} else {
|
|
this.showError(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
updateStreamInfo() {
|
|
const stream = this.streamData;
|
|
const isOnline = stream.publish?.active === true;
|
|
|
|
this.elements.streamTitle.textContent =
|
|
stream.name || `Stream ${this.streamId}`;
|
|
|
|
this.elements.streamStats.style.display = isOnline ? "flex" : "none";
|
|
|
|
if (isOnline) {
|
|
this.elements.viewerCount.textContent = stream.clients || 0;
|
|
this.elements.bitrate.textContent = this.formatBitrate(
|
|
stream.kbps?.recv_30s || 0,
|
|
);
|
|
this.elements.quality.textContent = this.getQualityString(stream);
|
|
}
|
|
|
|
this.elements.streamId.textContent = stream.name || this.streamId;
|
|
this.elements.streamApp.textContent = stream.app || "-";
|
|
this.elements.streamDuration.textContent = isOnline
|
|
? this.formatDuration(stream.publish?.duration)
|
|
: "-";
|
|
this.elements.videoCodec.textContent = stream.video
|
|
? `${stream.video.codec} ${stream.video.profile || ""}`.trim()
|
|
: "-";
|
|
this.elements.audioCodec.textContent = stream.audio?.codec || "-";
|
|
this.elements.resolution.textContent = stream.video
|
|
? `${stream.video.width || "?"}x${stream.video.height || "?"}`
|
|
: "-";
|
|
|
|
this.updateStreamUrls(stream);
|
|
}
|
|
|
|
updateStreamUrls(stream) {
|
|
const srsHost = new URL(this.srsUrl).host;
|
|
const streamPath = `${stream.app}/${stream.name}`;
|
|
|
|
this.elements.rtmpUrl.textContent = `rtmp://${srsHost}/${streamPath}`;
|
|
this.elements.hlsUrl.textContent = `${this.srsUrl}/${streamPath}.m3u8`;
|
|
this.elements.flvUrl.textContent = `${this.srsUrl}/${streamPath}.flv`;
|
|
this.elements.webrtcUrl.textContent = `webrtc://${srsHost}/${streamPath}`;
|
|
}
|
|
|
|
async setupVideoPlayer() {
|
|
const stream = this.streamData;
|
|
|
|
if (!stream.publish?.active) {
|
|
this.showError("Stream is currently offline");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.player &&
|
|
this.elements.videoPlayer &&
|
|
!this.elements.videoPlayer.paused
|
|
) {
|
|
this.showVideo();
|
|
return;
|
|
}
|
|
|
|
this.cleanupPlayer();
|
|
|
|
const videoElement = this.elements.videoPlayer;
|
|
const hlsUrl = `${this.srsUrl}/${stream.app}/${stream.name}.m3u8`;
|
|
const flvUrl = `${this.srsUrl}/${stream.app}/${stream.name}.flv`;
|
|
|
|
const hasFlvjs = typeof flvjs !== "undefined";
|
|
if (hasFlvjs && flvjs.isSupported()) {
|
|
await this.setupFlvjsPlayer(videoElement, flvUrl, hlsUrl);
|
|
} else {
|
|
await this.setupNativePlayer(videoElement, hlsUrl);
|
|
}
|
|
}
|
|
|
|
async setupFlvjsPlayer(videoElement, flvUrl, hlsUrl) {
|
|
try {
|
|
this.player = flvjs.createPlayer(
|
|
{
|
|
type: "flv",
|
|
url: flvUrl,
|
|
isLive: true,
|
|
cors: true,
|
|
},
|
|
{
|
|
enableWorker: false,
|
|
enableStashBuffer: false,
|
|
stashInitialSize: 128,
|
|
isLive: true,
|
|
lazyLoad: false,
|
|
lazyLoadMaxDuration: 0,
|
|
lazyLoadRecoverDuration: 0,
|
|
deferLoadAfterSourceOpen: false,
|
|
autoCleanupMaxBackwardDuration: 2,
|
|
autoCleanupMinBackwardDuration: 1,
|
|
statisticsInfoReportInterval: 1000,
|
|
fixAudioTimestampGap: true,
|
|
accurateSeek: false,
|
|
seekType: "range",
|
|
liveBufferLatencyChasing: true,
|
|
liveBufferLatencyMaxLatency: 1.5,
|
|
liveBufferLatencyMinRemain: 0.3,
|
|
},
|
|
);
|
|
|
|
const newVideoElement = this.setupVideoEvents(videoElement);
|
|
|
|
this.player.on(flvjs.Events.ERROR, (errorType, errorDetail) => {
|
|
console.warn(
|
|
"FLV.js error, falling back to HLS:",
|
|
errorType,
|
|
errorDetail,
|
|
);
|
|
this.cleanupPlayer();
|
|
setTimeout(() => this.setupNativePlayer(newVideoElement, hlsUrl), 100);
|
|
});
|
|
|
|
this.player.attachMediaElement(newVideoElement);
|
|
|
|
const loadPromise = new Promise((resolve, reject) => {
|
|
const loadTimeout = setTimeout(() => {
|
|
reject(new Error("FLV load timeout"));
|
|
}, 3000);
|
|
|
|
this.player.on(flvjs.Events.MEDIA_INFO, () => {
|
|
clearTimeout(loadTimeout);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
this.player.load();
|
|
|
|
try {
|
|
await loadPromise;
|
|
} catch {
|
|
console.warn("FLV.js load timeout, falling back to HLS");
|
|
this.cleanupPlayer();
|
|
await this.setupNativePlayer(newVideoElement, hlsUrl);
|
|
}
|
|
} catch (error) {
|
|
console.warn("FLV.js setup failed, using native player:", error);
|
|
await this.setupNativePlayer(videoElement, hlsUrl);
|
|
}
|
|
}
|
|
|
|
async setupNativePlayer(videoElement, url) {
|
|
try {
|
|
const newVideoElement = this.setupVideoEvents(videoElement);
|
|
|
|
newVideoElement.crossOrigin = "anonymous";
|
|
newVideoElement.preload = "metadata";
|
|
newVideoElement.autoplay = true;
|
|
newVideoElement.muted = true;
|
|
newVideoElement.playsInline = true;
|
|
newVideoElement.controls = false;
|
|
|
|
if (newVideoElement.canPlayType("application/vnd.apple.mpegurl")) {
|
|
newVideoElement.setAttribute("playsinline", "");
|
|
newVideoElement.setAttribute("webkit-playsinline", "");
|
|
}
|
|
|
|
newVideoElement.src = url;
|
|
newVideoElement.load();
|
|
} catch (error) {
|
|
console.error("Native player setup failed:", error);
|
|
this.showError("Failed to load video stream");
|
|
}
|
|
}
|
|
|
|
setupVideoEvents(videoElement) {
|
|
const newVideoElement = videoElement.cloneNode(true);
|
|
videoElement.parentNode.replaceChild(newVideoElement, videoElement);
|
|
this.elements.videoPlayer = newVideoElement;
|
|
|
|
newVideoElement.removeAttribute("src");
|
|
newVideoElement.load();
|
|
|
|
newVideoElement.addEventListener("loadstart", () => {
|
|
this.showLoading();
|
|
});
|
|
|
|
newVideoElement.addEventListener("loadedmetadata", () => {
|
|
this.attemptAutoplay();
|
|
});
|
|
|
|
newVideoElement.addEventListener("canplay", () => {
|
|
this.showVideo();
|
|
this.attemptAutoplay();
|
|
});
|
|
|
|
newVideoElement.addEventListener("playing", () => {
|
|
this.showVideo();
|
|
this.pendingPlay = false;
|
|
});
|
|
|
|
newVideoElement.addEventListener("waiting", () => {
|
|
setTimeout(() => {
|
|
if (newVideoElement.readyState < 3) {
|
|
this.showLoading();
|
|
}
|
|
}, 500);
|
|
});
|
|
|
|
newVideoElement.addEventListener("error", () => {
|
|
const error = newVideoElement.error;
|
|
console.error("Video error:", error);
|
|
|
|
if (this.retryCount < this.maxRetries) {
|
|
this.retryCount++;
|
|
setTimeout(
|
|
() => {
|
|
if (!this.isDestroyed) {
|
|
console.log(`Retrying video load (attempt ${this.retryCount})`);
|
|
const stream = this.streamData;
|
|
const hlsUrl = `${this.srsUrl}/${stream.app}/${stream.name}.m3u8`;
|
|
this.cleanupPlayer();
|
|
this.setupNativePlayer(newVideoElement, hlsUrl);
|
|
}
|
|
},
|
|
Math.min(1000 * this.retryCount, 5000),
|
|
);
|
|
} else {
|
|
this.showError(
|
|
`Video failed to load: ${error?.message || "Unknown error"}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
newVideoElement.addEventListener("pause", () => {
|
|
if (!this.isDestroyed && this.streamData?.publish?.active) {
|
|
setTimeout(() => {
|
|
if (newVideoElement.paused && !this.isDestroyed) {
|
|
this.attemptAutoplay();
|
|
}
|
|
}, 1000);
|
|
}
|
|
});
|
|
|
|
return newVideoElement;
|
|
}
|
|
|
|
async attemptAutoplay() {
|
|
if (!this.elements.videoPlayer || this.isDestroyed) return;
|
|
|
|
try {
|
|
this.elements.videoPlayer.muted = true;
|
|
|
|
const playPromise = this.elements.videoPlayer.play();
|
|
|
|
if (playPromise !== undefined) {
|
|
await playPromise;
|
|
console.log("Autoplay successful");
|
|
}
|
|
} catch (error) {
|
|
console.warn("Autoplay failed:", error);
|
|
|
|
if (!this.userInteracted) {
|
|
this.pendingPlay = true;
|
|
this.showPlayButton();
|
|
} else {
|
|
setTimeout(() => {
|
|
if (!this.elements.videoPlayer.paused) return;
|
|
this.elements.videoPlayer.play().catch(() => {});
|
|
}, 500);
|
|
}
|
|
}
|
|
}
|
|
|
|
showPlayButton() {
|
|
if (!document.getElementById("playButtonOverlay")) {
|
|
const overlay = document.createElement("div");
|
|
overlay.id = "playButtonOverlay";
|
|
overlay.style.cssText = `
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background: rgba(0,0,0,0.7);
|
|
color: white;
|
|
padding: 20px;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
font-size: 24px;
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 80px;
|
|
height: 80px;
|
|
`;
|
|
overlay.innerHTML = "▶";
|
|
|
|
overlay.addEventListener("click", () => {
|
|
this.userInteracted = true;
|
|
this.attemptAutoplay();
|
|
overlay.remove();
|
|
});
|
|
|
|
this.elements.videoContainer.style.position = "relative";
|
|
this.elements.videoContainer.appendChild(overlay);
|
|
}
|
|
}
|
|
|
|
cleanupPlayer() {
|
|
if (this.player) {
|
|
try {
|
|
this.player.pause();
|
|
this.player.unload();
|
|
this.player.detachMediaElement();
|
|
this.player.destroy();
|
|
} catch (e) {
|
|
console.warn("Error cleaning up player:", e);
|
|
}
|
|
this.player = null;
|
|
}
|
|
|
|
if (this.elements.videoPlayer) {
|
|
this.elements.videoPlayer.pause();
|
|
this.elements.videoPlayer.src = "";
|
|
this.elements.videoPlayer.removeAttribute("src");
|
|
this.elements.videoPlayer.load();
|
|
}
|
|
|
|
const overlay = document.getElementById("playButtonOverlay");
|
|
if (overlay) {
|
|
overlay.remove();
|
|
}
|
|
}
|
|
|
|
cleanup() {
|
|
this.isDestroyed = true;
|
|
this.stopAutoRefresh();
|
|
this.cleanupPlayer();
|
|
}
|
|
|
|
formatBitrate(kbps) {
|
|
if (kbps >= 1000) {
|
|
return `${(kbps / 1000).toFixed(1)} Mbps`;
|
|
}
|
|
return `${kbps} kbps`;
|
|
}
|
|
|
|
formatDuration(ms) {
|
|
if (!ms) return "0s";
|
|
const seconds = Math.floor(ms / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes % 60}m`;
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}m ${seconds % 60}s`;
|
|
}
|
|
return `${seconds}s`;
|
|
}
|
|
|
|
getQualityString(stream) {
|
|
if (!stream.video) return "Unknown";
|
|
const width = stream.video.width;
|
|
|
|
if (width >= 1920) return "1080p";
|
|
if (width >= 1280) return "720p";
|
|
if (width >= 854) return "480p";
|
|
if (width >= 640) return "360p";
|
|
return `${width}x${stream.video.height}`;
|
|
}
|
|
|
|
toggleFullscreen() {
|
|
if (!this.isFullscreen) {
|
|
this.elements.videoContainer.requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen().catch(() => {});
|
|
}
|
|
}
|
|
|
|
startAutoRefresh() {
|
|
if (this.refreshInterval) {
|
|
this.stopAutoRefresh();
|
|
}
|
|
|
|
this.refreshInterval = setInterval(() => {
|
|
if (!this.isDestroyed) {
|
|
this.loadStreamData();
|
|
}
|
|
}, 15000);
|
|
}
|
|
|
|
stopAutoRefresh() {
|
|
if (this.refreshInterval) {
|
|
clearInterval(this.refreshInterval);
|
|
this.refreshInterval = null;
|
|
}
|
|
}
|
|
|
|
showLoading() {
|
|
this.elements.loadingState.style.display = "flex";
|
|
this.elements.errorState.style.display = "none";
|
|
this.elements.videoPlayer.style.display = "none";
|
|
}
|
|
|
|
showError(message) {
|
|
this.elements.loadingState.style.display = "none";
|
|
this.elements.errorState.style.display = "flex";
|
|
this.elements.videoPlayer.style.display = "none";
|
|
this.elements.errorMessage.textContent = message;
|
|
}
|
|
|
|
showVideo() {
|
|
this.elements.loadingState.style.display = "none";
|
|
this.elements.errorState.style.display = "none";
|
|
this.elements.videoPlayer.style.display = "block";
|
|
}
|
|
}
|
|
|
|
try {
|
|
new StreamWatcher();
|
|
} catch (error) {
|
|
console.error("Failed to initialize StreamWatcher:", error);
|
|
}
|