srsViewer/public/js/watch.js
creations ae3224c18b
Some checks failed
Code quality checks / biome (push) Failing after 13s
first commit
2025-06-08 17:17:18 -04:00

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);
}