This commit is contained in:
commit
ae3224c18b
30 changed files with 2455 additions and 0 deletions
10
public/js/flv.min.js
vendored
Normal file
10
public/js/flv.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/flv.min.js.map
Normal file
1
public/js/flv.min.js.map
Normal file
File diff suppressed because one or more lines are too long
226
public/js/index.js
Normal file
226
public/js/index.js
Normal file
|
@ -0,0 +1,226 @@
|
|||
class StreamDashboard {
|
||||
constructor() {
|
||||
this.streams = [];
|
||||
this.filteredStreams = [];
|
||||
this.searchTimeout = null;
|
||||
this.refreshInterval = null;
|
||||
|
||||
this.initializeElements();
|
||||
this.bindEvents();
|
||||
this.loadStreams();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
initializeElements() {
|
||||
this.elements = {
|
||||
searchInput: document.getElementById("searchInput"),
|
||||
totalStreams: document.getElementById("totalStreams"),
|
||||
onlineStreams: document.getElementById("onlineStreams"),
|
||||
loadingState: document.getElementById("loadingState"),
|
||||
errorState: document.getElementById("errorState"),
|
||||
streamsContainer: document.getElementById("streamsContainer"),
|
||||
noResults: document.getElementById("noResults"),
|
||||
retryBtn: document.getElementById("retryBtn"),
|
||||
};
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.elements.searchInput.addEventListener("input", (e) => {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.filterStreams(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
this.elements.retryBtn.addEventListener("click", () => {
|
||||
this.loadStreams();
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
this.stopAutoRefresh();
|
||||
} else {
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.loadStreams(true);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadStreams(silentRefresh = false) {
|
||||
if (!silentRefresh) {
|
||||
this.showLoading();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/streams");
|
||||
|
||||
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 allStreams = result.data.streams || [];
|
||||
this.streams = allStreams.filter(
|
||||
(stream) => stream.publish?.active === true,
|
||||
);
|
||||
this.filteredStreams = [...this.streams];
|
||||
|
||||
this.updateStats();
|
||||
this.renderStreams();
|
||||
|
||||
if (!silentRefresh) {
|
||||
this.showStreams();
|
||||
}
|
||||
} catch {
|
||||
if (!silentRefresh) {
|
||||
this.showError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filterStreams(searchTerm) {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
|
||||
if (!term) {
|
||||
this.filteredStreams = [...this.streams];
|
||||
} else {
|
||||
this.filteredStreams = this.streams.filter(
|
||||
(stream) =>
|
||||
stream.name?.toLowerCase().includes(term) ||
|
||||
stream.url?.toLowerCase().includes(term) ||
|
||||
stream.app?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
this.renderStreams();
|
||||
|
||||
if (this.filteredStreams.length === 0 && term) {
|
||||
this.showNoResults();
|
||||
} else {
|
||||
this.showStreams();
|
||||
}
|
||||
}
|
||||
|
||||
updateStats() {
|
||||
const total = this.streams.length;
|
||||
|
||||
this.elements.totalStreams.textContent = total;
|
||||
this.elements.onlineStreams.textContent = total;
|
||||
}
|
||||
|
||||
renderStreams() {
|
||||
this.elements.streamsContainer.innerHTML = "";
|
||||
|
||||
for (const stream of this.filteredStreams) {
|
||||
const streamCard = this.createStreamCard(stream);
|
||||
this.elements.streamsContainer.appendChild(streamCard);
|
||||
}
|
||||
}
|
||||
|
||||
createStreamCard(stream) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "stream-card";
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!bytes) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const watchName = stream.name?.replace(/\/live\//, "/");
|
||||
const watchUrl = watchName
|
||||
? `${window.location.origin}/watch/${encodeURIComponent(watchName)}`
|
||||
: "Unknown Stream";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="stream-header">
|
||||
<h3 class="stream-name">${this.escapeHtml(stream.name || "Unknown Stream")}</h3>
|
||||
</div>
|
||||
|
||||
<div class="stream-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Clients:</span>
|
||||
<span class="info-value">${stream.clients || 0}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Bitrate:</span>
|
||||
<span class="info-value">${formatBytes((stream.kbps?.recv_30s || 0) * 1024)}/s</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Video:</span>
|
||||
<span class="info-value">${stream.video?.codec || "N/A"} ${stream.video?.profile || ""}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Audio:</span>
|
||||
<span class="info-value">${stream.audio?.codec || "N/A"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="${watchUrl}" class="stream-watch-link">
|
||||
<div class="stream-url" onclick="navigator.clipboard.writeText('${this.escapeHtml(watchUrl)}').then(() => this.style.background = 'rgba(34, 197, 94, 0.2)').catch(() => {})" title="Click to copy">
|
||||
<span class="stream-url-value">${this.escapeHtml(watchUrl)}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.elements.loadingState.style.display = "block";
|
||||
this.elements.errorState.style.display = "none";
|
||||
this.elements.streamsContainer.style.display = "none";
|
||||
this.elements.noResults.style.display = "none";
|
||||
}
|
||||
|
||||
showError() {
|
||||
this.elements.loadingState.style.display = "none";
|
||||
this.elements.errorState.style.display = "block";
|
||||
this.elements.streamsContainer.style.display = "none";
|
||||
this.elements.noResults.style.display = "none";
|
||||
}
|
||||
|
||||
showStreams() {
|
||||
this.elements.loadingState.style.display = "none";
|
||||
this.elements.errorState.style.display = "none";
|
||||
this.elements.streamsContainer.style.display = "grid";
|
||||
this.elements.noResults.style.display = "none";
|
||||
}
|
||||
|
||||
showNoResults() {
|
||||
this.elements.loadingState.style.display = "none";
|
||||
this.elements.errorState.style.display = "none";
|
||||
this.elements.streamsContainer.style.display = "none";
|
||||
this.elements.noResults.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
new StreamDashboard();
|
631
public/js/watch.js
Normal file
631
public/js/watch.js
Normal file
|
@ -0,0 +1,631 @@
|
|||
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);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue