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