PluginProbe
WpStream – Live Streaming, Video on Demand, Pay Per View / 4.8
WpStream – Live Streaming, Video on Demand, Pay Per View v4.8
4.14.1 4.14.0 4.13.2 4.13.1 4.13 4.12.5 4.12.4 4.12.3 4.12.2 4.12.1 4.12 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5 4.5.1 4.5.11 4.5.11.1 4.5.11.2 4.5.11.4 4.5.11.5 4.5.11.6 All 181 releases
wpstream / public / js / broadcaster.js

broadcaster.js in WpStream – Live Streaming, Video on Demand, Pay Per View 4.8, at public/js/broadcaster.js

823 lines 21.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WpStream Broadcaster
3 * @typedef {Object} WpStreamBroadcasterVars
4 * @property {string} whip_url - The WHIP URL for streaming
5 * @property {string} channel_id - The channel ID
6 * @property {string} ajax_url - The AJAX URL for requests
7 * @property {string} no_video_audio_access - Error message for no video/audio access
8 * @property {string} no_audio_access - Error message for no audio access
9 * @property {string} no_video_access - Error message for no video access
10 * @property {string} channel_off - Message when channel is off
11 */
12 /* global wpstream_broadcaster_vars */
13 document.addEventListener("DOMContentLoaded", function () {
14 // Global variables
15 let allDevices = null;
16 let input = null;
17 let streamingStarted = false;
18 let frameCalculatorTimer = null;
19 let totalVideoFrames = 0;
20 let whipUrl = null;
21 let videoEnabled = true;
22 let audioEnabled = true;
23 let localStream = null;
24 let considerReconnect = false; // set true after a successful start to allow auto-reconnects
25 let pendingReconnect = false; // true while waiting to reconnect
26 let pendingReconnectTimeout = null; // timeout handle for scheduled reconnect
27 const reconnectDelayMs = 15000; // 10s delay before attempting to reconnect
28
29 // Get WHIP URL from config
30 if (wpstream_broadcaster_vars && wpstream_broadcaster_vars.whip_url) {
31 whipUrl = wpstream_broadcaster_vars.whip_url;
32 }
33
34 // DOM elements
35 const videoElement = document.getElementById("localVideo");
36 const streamingButton = document.getElementById("startBroadcast");
37 const stopButton = document.getElementById("stopBroadcast");
38 const videoSourceSelect = document.getElementById("videoDevice");
39 const videoToggle = document.getElementById("videoToggle");
40 const videoResolutionSelect = document.getElementById("videoQuality");
41 const audioSourceSelect = document.getElementById("audioDevice");
42 const audioToggle = document.getElementById("audioToggle");
43 const messageContainer = document.getElementById("messageContainer");
44 const statusIndicator = document.getElementById("statusIndicator");
45 const statusText = document.getElementById("statusText");
46 const liveIndicatorLive = document.getElementById("videoLiveIndicatorLive");
47 const liveIndicatorError = document.getElementById("videoLiveIndicatorError");
48
49 // Resolution mappings from demo
50 const userResolutions = {
51 vga: {
52 width: { ideal: 640 },
53 height: { ideal: 480 },
54 },
55 hd: {
56 width: { ideal: 1280 },
57 height: { ideal: 720 },
58 },
59 fhd: {
60 width: { ideal: 1920 },
61 height: { ideal: 1080 },
62 },
63 square: {
64 width: { ideal: 800 },
65 height: { ideal: 600 },
66 },
67 default: {
68 width: { ideal: 1280 },
69 height: { ideal: 720 },
70 },
71 };
72
73 const displayResolutions = {
74 vga: { width: 640, height: 480 },
75 hd: { width: 1280, height: 720 },
76 fhd: { width: 1920, height: 1080 },
77 square: { width: 800, height: 600 },
78 default: { width: 1280, height: 720 },
79 };
80
81 function getResolutionAndCalculateFrame(videoElement) {
82 if (frameCalculatorTimer) {
83 clearInterval(frameCalculatorTimer);
84 frameCalculatorTimer = null;
85 totalVideoFrames = 0;
86 }
87
88 frameCalculatorTimer = setInterval(function () {
89 console.log(
90 "Resolution: " +
91 videoElement.videoWidth +
92 "x" +
93 videoElement.videoHeight
94 );
95
96 if (totalVideoFrames === 0) {
97 totalVideoFrames =
98 videoElement.getVideoPlaybackQuality().totalVideoFrames;
99 } else {
100 let currentTotalFrame =
101 videoElement.getVideoPlaybackQuality().totalVideoFrames;
102 let frameRate = currentTotalFrame - totalVideoFrames;
103 // console.log('Frame rate: ' + frameRate + 'fps');
104 totalVideoFrames = currentTotalFrame;
105 }
106 }, 1000);
107 }
108
109 function getUserConstraints() {
110 let videoDeviceId = videoSourceSelect.value;
111 let videoResolution = videoResolutionSelect.value;
112 let audioDeviceId = audioSourceSelect.value;
113
114 let newConstraint = {};
115
116 if (videoDeviceId) {
117 newConstraint.video = {
118 deviceId: {
119 exact: videoDeviceId,
120 },
121 };
122 }
123
124 if (audioDeviceId) {
125 newConstraint.audio = {
126 deviceId: {
127 exact: audioDeviceId,
128 },
129 };
130 }
131
132 if (videoResolution && userResolutions[videoResolution]) {
133 const resolution = userResolutions[videoResolution];
134
135 if (!newConstraint.video) {
136 newConstraint.video = {};
137 }
138
139 newConstraint.video.width = resolution.width;
140 newConstraint.video.height = resolution.height;
141 }
142
143 return newConstraint;
144 }
145
146 function getDisplayConstraints() {
147 let videoResolution = videoResolutionSelect.value;
148
149 let newConstraint = {};
150 newConstraint.video = {};
151
152 if (videoResolution && displayResolutions[videoResolution]) {
153 const resolution = displayResolutions[videoResolution];
154 newConstraint.video.width = resolution.width;
155 newConstraint.video.height = resolution.height;
156 } else {
157 newConstraint.video = true;
158 }
159
160 newConstraint.audio = true;
161 return newConstraint;
162 }
163
164 function setDevice(type, select, devices) {
165 select.innerHTML = "";
166
167 if (type === "audio" && devices.length === 0) {
168 const option = document.createElement("option");
169 option.value = "";
170 option.textContent = "No Source Available";
171 select.appendChild(option);
172 } else {
173 devices.forEach(function (device) {
174 const option = document.createElement("option");
175 option.textContent =
176 device.label || `${type} ${select.options.length + 1}`;
177 option.value = device.deviceId;
178 select.appendChild(option);
179 });
180 }
181
182 if (select.options.length > 0) {
183 select.selectedIndex = 0;
184 }
185 }
186
187 function resetMessages() {
188 if (messageContainer) {
189 messageContainer.innerHTML = "";
190 }
191
192 clearInterval(frameCalculatorTimer);
193 frameCalculatorTimer = null;
194 }
195
196 function showMessage(message, type = "info") {
197 if (!messageContainer) return;
198
199 const messageElement = document.createElement("div");
200 messageElement.className = type + "-message";
201 messageElement.textContent = message;
202
203 messageContainer.innerHTML = "";
204 messageContainer.appendChild(messageElement);
205
206 if (type === "success" || type === "info") {
207 setTimeout(() => {
208 messageElement.remove();
209 }, 5000);
210 }
211 }
212
213 function updateStatus(status) {
214 if (!statusIndicator || !statusText) return;
215
216 statusIndicator.classList.remove(
217 "connected",
218 "disconnected",
219 "connecting"
220 );
221
222 switch (status) {
223 case "connected":
224 statusIndicator.classList.add("connected");
225 statusText.textContent = "Connected - Broadcasting Live";
226 liveIndicatorLive.style.display = 'inline';
227 liveIndicatorError.style.display = 'none';
228 break;
229 case "connecting":
230 statusIndicator.classList.add("connecting");
231 statusText.textContent = "Connecting...";
232 liveIndicatorError.style.display = 'inline';
233 liveIndicatorLive.innerContent = 'Connecting';
234 break;
235 case "disconnected":
236 default:
237 statusIndicator.classList.add("disconnected");
238 statusText.textContent = "Not Broadcasting";
239 liveIndicatorLive.style.display = 'none';
240 liveIndicatorError.style.display = 'none';
241 break;
242 }
243 }
244
245 function createInput( shouldAutoStart = false ) {
246 if (streamingButton) {
247 streamingButton.disabled = true;
248 }
249
250 if (input) {
251 input.remove();
252 input = null;
253 }
254
255 resetMessages();
256
257 input = OvenLiveKit.create({
258 callbacks: {
259 error: function (error) {
260 let errorMessage = "";
261
262 if (error.message) {
263 errorMessage = error.message;
264 } else if (error.name) {
265 errorMessage = error.name;
266 } else {
267 errorMessage = error.toString();
268 }
269
270 if (errorMessage === "OverconstrainedError") {
271 errorMessage =
272 "The input device does not support the specified resolution or frame rate.";
273 }
274
275 resetMessages();
276 showMessage(errorMessage, "error");
277
278 if ( shouldAutoStart) {
279 considerReconnect = false;
280 }
281 },
282 connectionClosed: function (type, event) {
283 console.log("Connection closed:", type, event);
284 streamingStarted = false;
285 updateStatus("disconnected");
286
287 if (streamingButton) {
288 streamingButton.classList.remove("hidden");
289 streamingButton.disabled = false;
290 }
291 if (stopButton) {
292 stopButton.classList.add("hidden");
293 }
294
295 if (considerReconnect && !pendingReconnect) {
296 console.log('connection closed, attempting to reconnect');
297 attemptReconnect();
298 liveIndicatorLive.style.display = 'none';
299 liveIndicatorError.style.display = 'inline';
300 liveIndicatorError.innerContent = 'Reconnecting';
301 } else {
302 console.log('connection closed, not reconnecting');
303 updateInputState(false);
304 }
305 },
306 iceStateChange: function (state) {
307 console.log("ICE state changed:", state);
308 if ( state === 'connected' ) {
309 showMessage("Broadcast started successfully");
310 }
311
312 if (state === "disconnected" && considerReconnect) {
313 streamingStarted = false;
314 updateStatus("disconnected");
315
316 if (considerReconnect && !pendingReconnect) {
317 console.log('connection closed, attempting to reconnect from ice state change');
318 showMessage("Connection lost, attempting to reconnect...", "info");
319 attemptReconnect();
320 } else {
321 showMessage(
322 "Connection failed. Please check your network settings.",
323 "error"
324 );
325 }
326 }
327 },
328 },
329 });
330
331 input.attachMedia(videoElement);
332
333 if (videoSourceSelect.value) {
334 if (videoSourceSelect.value === "displayCapture") {
335 input
336 .getDisplayMedia(getDisplayConstraints())
337 .then(function (stream) {
338 localStream = stream;
339 if (streamingButton) {
340 streamingButton.disabled = false;
341 }
342
343 if ( shouldAutoStart && considerReconnect ) {
344 startStreaming(true);
345 }
346 })
347 .catch(function (error) {
348 console.error('Failed to get display media:', error);
349 if ( shouldAutoStart ) {
350 showMessage('Failed to access screen sharing.', 'error');
351 considerReconnect = false;
352 updateInputState(false);
353 }
354 });
355 } else {
356 input
357 .getUserMedia(getUserConstraints())
358 .then(function (stream) {
359 localStream = stream;
360 if (streamingButton) {
361 streamingButton.disabled = false;
362 }
363
364 if ( shouldAutoStart && considerReconnect ) {
365 startStreaming(true);
366 }
367 })
368 .catch(function (error) {
369 console.error('Failed to get user media:', error);
370 if ( shouldAutoStart ) {
371 showMessage('Failed to access camera/microphone.', 'error');
372 considerReconnect = false;
373 updateInputState(false);
374 }
375 });
376 }
377 }
378 }
379
380 function startStreaming( isReconnect = false) {
381 // make an ajax call to check if the channel is active and if the user has quota
382 const channelCheckPromise = checkChannelStatus(wpstream_broadcaster_vars.channel_id);
383 const channelUserQuotaPromise = checkUserQuota();
384
385 Promise.all([channelCheckPromise, channelUserQuotaPromise])
386 .then(function (results) {
387 const channelActive = results[0];
388 const userQuotaValid = results[1];
389
390 if (!channelActive) {
391 resetStreamingUI();
392 considerReconnect = false;
393 return; // Channel is not active, do not proceed with streaming
394 }
395
396 if (!userQuotaValid) {
397 resetStreamingUI();
398 considerReconnect = false;
399 return; // User quota is not valid, do not proceed with streaming
400 }
401
402 proceedWithStreaming(isReconnect);
403 });
404 }
405
406 function updateInputState(state) {
407 videoSourceSelect.disabled = state;
408 audioSourceSelect.disabled = state;
409 videoResolutionSelect.disabled = state;
410 streamingButton.disabled = state;
411 }
412
413 function stopStreaming() {
414 streamingStarted = false;
415 considerReconnect = false; // user-initiated stop should cancel auto-reconnect
416 if (pendingReconnect && pendingReconnectTimeout) {
417 clearTimeout(pendingReconnectTimeout);
418 pendingReconnect = false;
419 pendingReconnectTimeout = null;
420 }
421 updateStatus("disconnected");
422
423 if (streamingButton) {
424 streamingButton.classList.remove("hidden");
425 }
426 if (stopButton) {
427 stopButton.classList.add("hidden");
428 }
429
430 if (input) {
431 input.stopStreaming();
432 createInput();
433 }
434
435 showMessage("Broadcasting stopped", "info");
436 updateInputState(false);
437 }
438
439 function attemptReconnect() {
440 console.log("attemptReconnect()");
441
442 if ( pendingReconnect ) {
443 console.log('reconnect already in progress');
444 return;
445 }
446
447 // Clean up existing connection before reconnecting
448 if (input) {
449 if (input.peerConnection || input.webSocket) {
450 // Force cleanup without triggering callbacks
451 if (input.peerConnection) {
452 input.peerConnection.close();
453 input.peerConnection = null;
454 }
455 if (input.webSocket) {
456 input.webSocket.close();
457 input.webSocket = null;
458 }
459 // Reset streaming mode
460 input.streamingMode = null;
461 }
462 }
463
464 pendingReconnect = true;
465
466 // Show a reconnecting state and allow user to cancel via Stop button
467 updateStatus("connecting");
468 showMessage("Disconnected. Reconnecting in 5 seconds...", "info");
469 updateInputState(true);
470
471 pendingReconnect = true;
472 pendingReconnectTimeout = setTimeout(function () {
473 pendingReconnect = false;
474 pendingReconnectTimeout = null;
475 if (considerReconnect) {
476 checkChannelStatus(wpstream_broadcaster_vars.channel_id)
477 .then(function(channelActive) {
478 if (channelActive && considerReconnect) {
479 console.log("Channel is active, proceeding with reconnect...");
480 input.stopStreaming();
481 setTimeout(function () {
482 createInput(true);
483 }, 15000);
484 } else {
485 console.log("Channel is not active, cannot reconnect.");
486 considerReconnect = false;
487 showMessage('Channel is no longer active. Broadcasting stopped');
488 resetStreamingUI();
489 }
490 })
491 .catch(function (error) {
492 console.error('Error checking channel status');
493 if (considerReconnect) {
494 console.error('Error during reconnect attempt:', error);
495 attemptReconnect();
496 }
497 });
498
499 // console.log('Reconnecting...');
500 // createInput( true );
501 // startStreaming();
502 }
503 }, reconnectDelayMs);
504 }
505
506 function toggleVideo(enabled) {
507 let stream = localStream;
508
509 if ( enabled ) {
510 document.getElementById('video-off').style.display = 'inline';
511 document.getElementById('video-on').style.display = 'none';
512 } else {
513 document.getElementById('video-on').style.display = 'inline';
514 document.getElementById('video-off').style.display = 'none';
515 }
516
517 if (!stream && videoElement && videoElement.srcObject) {
518 stream = videoElement.srcObject;
519 }
520
521 if (stream) {
522 const videoTracks = stream.getVideoTracks();
523
524 videoTracks.forEach((track) => {
525 track.enabled = enabled;
526 });
527
528 showMessage(enabled ? "Video enabled" : "Video disabled", "info");
529 }
530 }
531
532 function toggleAudio(enabled) {
533 let stream = localStream;
534
535 if ( enabled ) {
536 document.getElementById('audio-off').style.display = 'inline';
537 document.getElementById('audio-on').style.display = 'none';
538 } else {
539 document.getElementById('audio-on').style.display = 'inline';
540 document.getElementById('audio-off').style.display = 'none';
541 }
542
543 if (!stream && videoElement && videoElement.srcObject) {
544 stream = videoElement.srcObject;
545 }
546
547 if (stream) {
548 const audioTracks = stream.getAudioTracks();
549
550 audioTracks.forEach((track) => {
551 track.enabled = enabled;
552 });
553
554 showMessage(enabled ? "Audio enabled" : "Audio disabled", "info");
555 }
556 }
557
558 // Event listeners
559 if (streamingButton) {
560 streamingButton.addEventListener("click", function () {
561 if (!streamingStarted) {
562 updateStatus('connecting');
563 streamingButton.classList.add("hidden");
564 stopButton.classList.remove("hidden");
565 startStreaming();
566 }
567 });
568 }
569
570 if (stopButton) {
571 stopButton.addEventListener("click", function () {
572 // If a reconnect is pending, treat this as "Cancel Broadcast"
573 if (pendingReconnect) {
574 if (pendingReconnectTimeout) {
575 clearTimeout(pendingReconnectTimeout);
576 pendingReconnectTimeout = null;
577 }
578 pendingReconnect = false;
579 considerReconnect = false;
580 updateStatus("disconnected");
581 showMessage("Broadcast stopped", "info");
582 updateInputState(false);
583 if (stopButton) {
584 stopButton.classList.add("hidden");
585 }
586 if (streamingButton) {
587 streamingButton.classList.remove("hidden");
588 streamingButton.disabled = false;
589 }
590 return;
591 }
592
593 if (streamingStarted) {
594 stopStreaming();
595 }
596 });
597 }
598
599 // Device change listeners
600 if (videoSourceSelect) {
601 videoSourceSelect.addEventListener("change", function () {
602 if (input) {
603 createInput();
604 }
605 });
606 }
607
608 if (videoResolutionSelect) {
609 videoResolutionSelect.addEventListener("change", function () {
610 if (input) {
611 createInput();
612 }
613 });
614 }
615
616 if (audioSourceSelect) {
617 audioSourceSelect.addEventListener("change", function () {
618 if (input) {
619 createInput();
620 }
621 });
622 }
623
624 if (videoToggle) {
625 videoToggle.addEventListener("click", function () {
626 videoEnabled = !videoEnabled;
627 toggleVideo(videoEnabled);
628 });
629 }
630
631 if (audioToggle) {
632 audioToggle.addEventListener("click", function () {
633 audioEnabled = !audioEnabled;
634 toggleAudio(audioEnabled);
635 });
636 }
637
638 function init() {
639 if (allDevices) {
640 setDevice("video", videoSourceSelect, allDevices.videoinput);
641 setDevice("audio", audioSourceSelect, allDevices.audioinput);
642 }
643
644 createInput();
645 }
646
647 // Initialize - get all devices first
648 OvenLiveKit.getDevices()
649 .then(function (devices) {
650 allDevices = devices;
651 init();
652 })
653 .catch(function (error) {
654 let errorMessage = "";
655
656 if (error.message) {
657 console.log(error.message);
658 changeErrorMessage(error.message);
659 } else if (error.name) {
660 errorMessage = error.name;
661 showMessage(errorMessage, "error");
662 } else {
663 errorMessage = error.toString();
664 showMessage(errorMessage, "error");
665 }
666 });
667
668 function changeErrorMessage(message) {
669 console.log(wpstream_broadcaster_vars);
670 switch (message) {
671 case "No input devices were found.":
672 showMessage(
673 wpstream_broadcaster_vars.no_video_audio_access,
674 "error"
675 );
676 break;
677 case "Can not find Audio devices":
678 showMessage(wpstream_broadcaster_vars.no_audio_access, "error");
679 break;
680 case "Can not find Video devices":
681 showMessage(wpstream_broadcaster_vars.no_video_access, "error");
682 }
683 }
684
685 function checkChannelStatus(channelId) {
686 return new Promise((resolve, reject) => {
687 if (!wpstream_broadcaster_vars.ajax_url) {
688 resolve(true);
689 return;
690 }
691
692 jQuery.ajax({
693 url: wpstream_broadcaster_vars.ajax_url,
694 type: 'POST',
695 data: {
696 action: 'wpstream_check_event_status',
697 channel_id: channelId,
698 },
699 success: function(response) {
700 try {
701 const parsedResponse = JSON.parse(response);
702 if (parsedResponse.status === 'active') {
703 resolve(true);
704 } else {
705 showMessage(wpstream_broadcaster_vars.channel_off, 'error');
706 resolve(false);
707 }
708 } catch (e) {
709 console.error('Error parsing response:', e);
710 showMessage('Error checking channel status', 'error');
711 reject(false);
712 }
713 },
714 error: function(xhr, status, error) {
715 console.error('Error checking channel status:', error);
716 showMessage('Error checking channel status: ' + error, 'error');
717 reject(false);
718 }
719 })
720 })
721 }
722
723 function checkUserQuota() {
724 return new Promise((resolve, reject) => {
725 if (!wpstream_broadcaster_vars.ajax_url) {
726 resolve(true);
727 return;
728 }
729
730 jQuery.ajax({
731 url: wpstream_broadcaster_vars.ajax_url,
732 type: 'POST',
733 data: {
734 action: 'wpstream_check_user_quota',
735 },
736 success: function(response) {
737 try {
738 const parsedResponse = JSON.parse(response);
739 if (parsedResponse.available_data_mb > 0) {
740 resolve(true);
741 } else {
742 const messageElement = document.createElement("div");
743 messageElement.className = "error-message";
744 messageElement.innerHTML = wpstream_broadcaster_vars.not_enough_traffic;
745 messageContainer.innerHTML = "";
746 messageContainer.appendChild(messageElement);
747 resolve(false);
748 }
749 } catch (e) {
750 console.error('Error parsing response:', e);
751 showMessage('Error checking quota', 'error');
752 reject(false);
753 }
754 },
755 error: function(xhr, status, error) {
756 console.error('Error checking user quota:', error);
757 showMessage('Error checking user quota: ' + error, 'error');
758 reject(false);
759 }
760 })
761 })
762 }
763
764 function proceedWithStreaming(isReconnect) {
765 streamingStarted = true;
766 // If a reconnect was pending, cancel it as we're actively starting now
767 if (pendingReconnect && pendingReconnectTimeout) {
768 clearTimeout(pendingReconnectTimeout);
769 pendingReconnect = false;
770 pendingReconnectTimeout = null;
771 }
772
773 updateStatus("connecting");
774
775 if (streamingButton) {
776 streamingButton.classList.add("hidden");
777 }
778 if (stopButton) {
779 stopButton.classList.remove("hidden");
780
781 }
782
783 if (input && whipUrl) {
784 let connectionConfig = {};
785
786 // Begin streaming; mark that we should auto-reconnect on unexpected disconnects
787 considerReconnect = true;
788 input.startStreaming(whipUrl, connectionConfig);
789 if ( input ) {
790 console.log('something was wrong' );
791 }
792 updateStatus("connected");
793 if ( isReconnect ) {
794 console.log('Reconnected successfully!');
795 showMessage("Reconnected successfully!", "success");
796 }
797 updateInputState(true);
798 } else {
799 streamingButton.classList.remove("hidden");
800 stopButton.classList.add("hidden");
801 console.log(whipUrl);
802 showMessage("Error: No WHIP URL configured", "error");
803 updateStatus("disconnected");
804
805 // Stop reconnecting if there's an error
806 if ( isReconnect ) {
807 considerReconnect = false;
808 }
809 }
810 }
811
812 function resetStreamingUI() {
813 updateStatus("disconnected");
814 if (streamingButton) {
815 streamingButton.classList.remove("hidden");
816 streamingButton.disabled = false;
817 }
818 if (stopButton) {
819 stopButton.classList.add("hidden");
820 }
821 }
822 });
823