mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
26.5 sync with alpha
This commit is contained in:
925
ipcam.html
Normal file
925
ipcam.html
Normal file
@@ -0,0 +1,925 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="IP Camera streaming tool with secure E2EE P2P sharing via VDO.Ninja and WHIP support">
|
||||
<title>IP Camera Stream</title>
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
font-family: system-ui;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.preview-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden; /* Add this to prevent overflow */
|
||||
min-height: 0; /* This is crucial for proper flexbox behavior */
|
||||
}
|
||||
|
||||
#preview {
|
||||
max-width: 100%;
|
||||
max-height: 100%; /* Change from calc(100vh - 140px) to 100% */
|
||||
object-fit: contain; /* Add this to maintain aspect ratio */
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
width: auto; /* Add this to prevent stretching */
|
||||
height: auto; /* Add this to prevent stretching */
|
||||
}
|
||||
|
||||
.stream-container {
|
||||
flex: 1;
|
||||
background: #2a2a2a;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
min-height: 0; /* Add this to prevent flex container from expanding */
|
||||
overflow: hidden; /* Add this to prevent overflow */
|
||||
}
|
||||
.controls {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.camera-url {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #404040;
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
}
|
||||
button {
|
||||
background: #404040;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover {
|
||||
background: #505050;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.stats {
|
||||
position: absolute;
|
||||
bottom: 45px;
|
||||
left: 10px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.publish-info {
|
||||
background: rgba(0,0,0,0.7);
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal {
|
||||
background: #2a2a2a;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
color: white;
|
||||
}
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.modal-buttons button {
|
||||
flex: 1;
|
||||
}
|
||||
.modal-buttons button.active {
|
||||
outline: 2px solid #898989;
|
||||
}
|
||||
.modal-content {
|
||||
display: none;
|
||||
}
|
||||
.modal-content.active {
|
||||
display: block;
|
||||
}
|
||||
.input-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.modal input[type="text"],
|
||||
.modal input[type="url"],
|
||||
.modal input[type="password"] {
|
||||
width: calc(100% - 20px);
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
}
|
||||
.page-description {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
color: #898989;
|
||||
font-size: 0.9em;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 0px auto;
|
||||
padding: 0 0 15px 0;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.dragbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height:50px;
|
||||
width:100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.modal h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.modal .subtitle {
|
||||
text-align: center;
|
||||
color: #898989;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.9em;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.mode-description {
|
||||
padding-bottom: 20px;
|
||||
color: #a7a7a7;
|
||||
text-align: center;
|
||||
}
|
||||
.view-link {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #404040;
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
width: 100%;
|
||||
}
|
||||
.view-link-container {
|
||||
display: none;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.electron-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
display: none;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 900;
|
||||
backdrop-filter: blur(5px);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.electron-banner a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.electron-banner a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.electron-banner-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.electron-banner-close:hover {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-description">
|
||||
This tool allows you to stream IP camera feeds (MJPEG) to various streaming platforms.
|
||||
Configure your publishing settings first, then enter your camera's URL to begin streaming.
|
||||
</div>
|
||||
<div class="dragbar"></div>
|
||||
|
||||
<div class="stream-container">
|
||||
<div id="publishInfo" class="publish-info" style="display: none;">
|
||||
<div id="publishInfoText"></div>
|
||||
<div class="view-link-container" style="display: none;">
|
||||
<input type="text" id="viewLink" readonly class="view-link">
|
||||
<button onclick="copyViewLink()" class="copy-button">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input type="url" class="camera-url" id="cameraUrl" placeholder="Enter MJPEG stream URL (e.g., http://192.168.0.100/api/stream/live?cam)">
|
||||
<button id="previewStream">Preview</button>
|
||||
<button id="publishStream" style="display: none;">Publish</button>
|
||||
<button id="stopStream" disabled>Stop</button>
|
||||
<button id="configurePublishing">Configure Publishing</button>
|
||||
</div>
|
||||
<div class="preview-container">
|
||||
<img id="preview" alt="IP Camera Stream">
|
||||
<div class="stats">
|
||||
FPS: <span id="fpsCounter">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3>Publishing Configuration</h3>
|
||||
<div class="subtitle">
|
||||
Configure how you want to publish your IP camera feed.
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button onclick="showMode('vdo')" title="Peer-to-peer encrypted streaming via VDO.Ninja">VDO.Ninja</button>
|
||||
<button onclick="showMode('whip')" title="Stream to any WHIP-compatible service">WHIP</button>
|
||||
<button onclick="showMode('twitch')" title="Stream directly to Twitch">Twitch</button>
|
||||
<button onclick="showMode('preview')" title="Local preview only - no streaming">Preview</button>
|
||||
</div>
|
||||
<div class="mode-description" id="modeDescription"></div>
|
||||
|
||||
<div class="modal-content" id="vdo-content">
|
||||
<div class="input-group">
|
||||
<label>Stream ID (optional)</label>
|
||||
<input type="text" id="pushId" placeholder="Leave empty for auto-generated ID">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Room Name (optional if Stream ID provided)</label>
|
||||
<input type="text" id="roomName">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Password (optional)</label>
|
||||
<input type="password" id="password">
|
||||
</div>
|
||||
<button onclick="handleVDO()">Save Configuration</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-content" id="twitch-content">
|
||||
<div class="input-group">
|
||||
<label>Twitch Stream Token</label>
|
||||
<input type="password" id="twitchToken" required>
|
||||
</div>
|
||||
<button onclick="handleTwitch()">Save Configuration</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-content" id="whip-content">
|
||||
<div class="input-group">
|
||||
<label>WHIP URL</label>
|
||||
<input type="url" id="whipUrl" required>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>WHIP Token</label>
|
||||
<input type="password" id="whipToken">
|
||||
</div>
|
||||
<button onclick="handleWhip()">Save Configuration</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-content" id="preview-content">
|
||||
<p>Preview mode - no publishing. Click Save to continue.</p>
|
||||
<button onclick="handlePreview()">Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="electron-banner">
|
||||
<div>
|
||||
For best compatibility with IP camera streams, we recommend using
|
||||
<a href="https://electroncapture.app" target="_blank">ElectronCapture</a>
|
||||
(available for Windows, Mac, and Linux).
|
||||
</div>
|
||||
<button class="electron-banner-close" onclick="dismissBanner()">×</button>
|
||||
</div>
|
||||
<script>
|
||||
let streamActive = false;
|
||||
let frameCount = 0;
|
||||
let lastFrameTime = Date.now();
|
||||
let frameInterval;
|
||||
let publishConfig = null;
|
||||
let isPublishing = false;
|
||||
let currentBuffer = '';
|
||||
let currentStreamId = null;
|
||||
let buffer = '';
|
||||
let boundary = null;
|
||||
let useMediaTrackProcessor = false;
|
||||
|
||||
|
||||
const preview = document.getElementById('preview');
|
||||
const fpsCounter = document.getElementById('fpsCounter');
|
||||
const previewStreamBtn = document.getElementById('previewStream');
|
||||
const publishStreamBtn = document.getElementById('publishStream');
|
||||
const stopStreamBtn = document.getElementById('stopStream');
|
||||
const configurePublishingBtn = document.getElementById('configurePublishing');
|
||||
const cameraUrl = document.getElementById('cameraUrl');
|
||||
const publishInfo = document.getElementById('publishInfo');
|
||||
|
||||
function updatePublishInfo() {
|
||||
if (!publishConfig) {
|
||||
publishInfo.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let info = '';
|
||||
switch (publishConfig.mode) {
|
||||
case 'vdo':
|
||||
info = `Publishing to VDO.Ninja - ${publishConfig.push ? 'Stream ID: ' + publishConfig.push : 'Room: ' + publishConfig.room}`;
|
||||
document.querySelector('.view-link-container').style.display = 'flex';
|
||||
break;
|
||||
case 'whip':
|
||||
info = `Publishing to WHIP endpoint: ${publishConfig.whipUrl}`;
|
||||
document.querySelector('.view-link-container').style.display = 'none';
|
||||
break;
|
||||
case 'twitch':
|
||||
info = 'Publishing to Twitch';
|
||||
document.querySelector('.view-link-container').style.display = 'none';
|
||||
break;
|
||||
case 'preview':
|
||||
info = 'Preview mode - no publishing';
|
||||
document.querySelector('.view-link-container').style.display = 'none';
|
||||
break;
|
||||
}
|
||||
document.getElementById('publishInfoText').textContent = info;
|
||||
publishInfo.style.display = 'block';
|
||||
}
|
||||
|
||||
function updateUIState() {
|
||||
const isPreviewMode = publishConfig?.mode === 'preview';
|
||||
publishStreamBtn.style.display = isPreviewMode ? 'none' : 'inline-block';
|
||||
previewStreamBtn.disabled = streamActive;
|
||||
publishStreamBtn.disabled = !streamActive || isPublishing;
|
||||
stopStreamBtn.disabled = !streamActive;
|
||||
configurePublishingBtn.disabled = streamActive;
|
||||
cameraUrl.disabled = streamActive;
|
||||
}
|
||||
|
||||
function updateFPS() {
|
||||
const currentTime = Date.now();
|
||||
const elapsed = (currentTime - lastFrameTime) / 1000;
|
||||
const fps = Math.round(frameCount / elapsed);
|
||||
fpsCounter.textContent = fps;
|
||||
frameCount = 0;
|
||||
lastFrameTime = currentTime;
|
||||
}
|
||||
|
||||
function createAndAppendIframe(url) {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.width = '0';
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = 'none';
|
||||
iframe.src = url;
|
||||
document.body.appendChild(iframe);
|
||||
return iframe;
|
||||
}
|
||||
|
||||
function startPublishing() {
|
||||
if (!publishConfig || isPublishing) return;
|
||||
|
||||
// Always start with HTTPS for VDO.Ninja URLs
|
||||
let iframeUrl;
|
||||
// For VDO and WHIP modes, ensure we're using HTTPS
|
||||
iframeUrl = new URL('https://vdo.ninja');
|
||||
// Preserve the path from the current URL
|
||||
let pathParts = location.pathname.split('/').filter(Boolean);
|
||||
pathParts.pop();
|
||||
iframeUrl.pathname = (pathParts.length ? '/' + pathParts.join('/') : '') + '/';
|
||||
|
||||
switch (publishConfig.mode) {
|
||||
case 'vdo':
|
||||
iframeUrl.searchParams.set('push', publishConfig.push);
|
||||
if (publishConfig.room) iframeUrl.searchParams.set('room', publishConfig.room);
|
||||
if (publishConfig.password) iframeUrl.searchParams.set('password', publishConfig.password);
|
||||
break;
|
||||
case 'whip':
|
||||
iframeUrl.searchParams.set('whippush', publishConfig.whipUrl);
|
||||
iframeUrl.searchParams.set('push', '');
|
||||
if (publishConfig.whipToken) iframeUrl.searchParams.set('whippushtoken', publishConfig.whipToken);
|
||||
break;
|
||||
case 'twitch':
|
||||
iframeUrl.searchParams.set('whippush', 'https://twitch.vdo.ninja');
|
||||
iframeUrl.searchParams.set('push', '');
|
||||
iframeUrl.searchParams.set('token', publishConfig.twitchToken);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
iframeUrl.searchParams.set('framegrab', '');
|
||||
iframeUrl.searchParams.set('view', '');
|
||||
|
||||
window.publishingIframe = createAndAppendIframe(iframeUrl.toString());
|
||||
isPublishing = true;
|
||||
updateUIState();
|
||||
}
|
||||
|
||||
async function processStream(response, isElectron = false) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Stream initialization failed');
|
||||
}
|
||||
|
||||
const boundary = isElectron ? response.boundary : extractBoundary(response.headers.get('content-type'));
|
||||
if (!boundary) {
|
||||
throw new Error('No boundary found in content-type header');
|
||||
}
|
||||
|
||||
if (isElectron) {
|
||||
currentStreamId = response.streamId;
|
||||
await processElectronStream(response);
|
||||
} else {
|
||||
await processRegularStream(response, boundary);
|
||||
}
|
||||
}
|
||||
|
||||
function extractBoundary(contentType) {
|
||||
const match = contentType?.match(/boundary=([^;]+)/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function findBoundary(buffer, boundaryBytes, startPos) {
|
||||
for (let i = startPos; i < buffer.length - boundaryBytes.length; i++) {
|
||||
if (buffer[i] === boundaryBytes[0]) {
|
||||
let found = true;
|
||||
for (let j = 1; j < boundaryBytes.length; j++) {
|
||||
if (buffer[i + j] !== boundaryBytes[j]) {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function processFrame(frameData) {
|
||||
let jpegStart = -1;
|
||||
for (let i = 0; i < frameData.length - 4; i++) {
|
||||
if (frameData[i] === 0xFF && frameData[i + 1] === 0xD8) {
|
||||
jpegStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (jpegStart !== -1) {
|
||||
const jpegData = frameData.slice(jpegStart);
|
||||
const base64Data = arrayBufferToBase64(jpegData);
|
||||
const dataUrl = `data:image/jpeg;base64,${base64Data}`;
|
||||
|
||||
preview.src = dataUrl;
|
||||
frameCount++;
|
||||
|
||||
if (isPublishing && window.publishingIframe) {
|
||||
window.publishingIframe.contentWindow.postMessage({
|
||||
type: 'canvas-frame',
|
||||
frame: dataUrl
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processRegularStream(response, boundary) {
|
||||
const reader = response.body.getReader();
|
||||
let buffer = new Uint8Array(0);
|
||||
const boundaryBytes = new TextEncoder().encode(boundary);
|
||||
|
||||
while (streamActive) {
|
||||
try {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
if (value) {
|
||||
const newBuffer = new Uint8Array(buffer.length + value.length);
|
||||
newBuffer.set(buffer);
|
||||
newBuffer.set(value, buffer.length);
|
||||
buffer = newBuffer;
|
||||
|
||||
let frameStart = 0;
|
||||
while (true) {
|
||||
const boundaryPos = findBoundary(buffer, boundaryBytes, frameStart);
|
||||
if (boundaryPos === -1) {
|
||||
if (buffer.length > boundaryBytes.length) {
|
||||
buffer = buffer.slice(buffer.length - boundaryBytes.length);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const nextBoundary = findBoundary(buffer, boundaryBytes, boundaryPos + boundaryBytes.length);
|
||||
if (nextBoundary === -1) break;
|
||||
|
||||
const frameData = buffer.slice(boundaryPos, nextBoundary);
|
||||
processFrame(frameData);
|
||||
|
||||
buffer = buffer.slice(nextBoundary);
|
||||
frameStart = boundaryBytes.length;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing stream:', error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const chunks = [];
|
||||
const chunkSize = 32768;
|
||||
for (let i = 0; i < buffer.length; i += chunkSize) {
|
||||
chunks.push(String.fromCharCode.apply(null,
|
||||
buffer.subarray(i, Math.min(i + chunkSize, buffer.length))
|
||||
));
|
||||
}
|
||||
return btoa(chunks.join(''));
|
||||
}
|
||||
|
||||
async function processElectronStream(response) {
|
||||
let buffer = new Uint8Array(0);
|
||||
const boundaryBytes = new TextEncoder().encode(response.boundary);
|
||||
|
||||
while (streamActive) {
|
||||
try {
|
||||
const result = await window.electronApi.readStreamChunk(currentStreamId);
|
||||
if (result.done) break;
|
||||
|
||||
if (result.value) {
|
||||
const newData = new Uint8Array(result.value);
|
||||
const newBuffer = new Uint8Array(buffer.length + newData.length);
|
||||
newBuffer.set(buffer);
|
||||
newBuffer.set(newData, buffer.length);
|
||||
buffer = newBuffer;
|
||||
|
||||
let frameStart = 0;
|
||||
while (true) {
|
||||
let boundaryPos = findBoundary(buffer, boundaryBytes, frameStart);
|
||||
if (boundaryPos === -1) {
|
||||
if (buffer.length > boundaryBytes.length) {
|
||||
buffer = buffer.slice(buffer.length - boundaryBytes.length);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const nextBoundary = findBoundary(buffer, boundaryBytes, boundaryPos + boundaryBytes.length);
|
||||
if (nextBoundary === -1) break;
|
||||
|
||||
const frameData = buffer.slice(boundaryPos, nextBoundary);
|
||||
let jpegStart = -1;
|
||||
for (let i = 0; i < frameData.length - 4; i++) {
|
||||
if (frameData[i] === 0xFF && frameData[i + 1] === 0xD8) {
|
||||
jpegStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (jpegStart !== -1) {
|
||||
const jpegData = frameData.slice(jpegStart);
|
||||
const base64Data = arrayBufferToBase64(jpegData);
|
||||
const dataUrl = `data:image/jpeg;base64,${base64Data}`;
|
||||
|
||||
preview.src = dataUrl;
|
||||
frameCount++;
|
||||
|
||||
if (isPublishing && window.publishingIframe) {
|
||||
window.publishingIframe.contentWindow.postMessage({
|
||||
type: 'canvas-frame',
|
||||
frame: dataUrl
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
buffer = buffer.slice(nextBoundary);
|
||||
frameStart = boundaryBytes.length;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing stream:', error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStreamId) {
|
||||
await window.electronApi.closeStream(currentStreamId);
|
||||
currentStreamId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startStreaming() {
|
||||
if (streamActive) return;
|
||||
|
||||
const url = cameraUrl.value;
|
||||
if (!url) {
|
||||
alert('Please enter a camera URL');
|
||||
return;
|
||||
}
|
||||
|
||||
streamActive = true;
|
||||
frameInterval = setInterval(updateFPS, 1000);
|
||||
updateUIState();
|
||||
|
||||
try {
|
||||
if (window.electronApi?.noCORSFetch) {
|
||||
const electronResponse = await window.electronApi.noCORSFetch({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'multipart/x-mixed-replace'
|
||||
}
|
||||
});
|
||||
bannerDismissed = true;
|
||||
checkElectronBanner();
|
||||
await processStream(electronResponse, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'multipart/x-mixed-replace'
|
||||
}
|
||||
});
|
||||
bannerDismissed = true;
|
||||
checkElectronBanner();
|
||||
await processStream(response, false);
|
||||
} catch (error) {
|
||||
const isSecureContext = window.isSecureContext;
|
||||
const isHttps = url.toLowerCase().startsWith('https://');
|
||||
|
||||
if (!isHttps && isSecureContext) {
|
||||
if (location.href.includes("vdo.ninja")) {
|
||||
let insecure = "http://insecure.vdo.ninja" + location.pathname;
|
||||
alert('Mixed Content Error:\n\nStream URL must use HTTPS when accessing from a secure page\n\nTry via '+insecure+' instead.');
|
||||
} else {
|
||||
alert('Mixed Content Error:\n\nStream URL must use HTTPS when accessing from a secure page\n\nTry via ElectronCapture. Please download from:\nhttps://electroncapture.app');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TypeError && error.message === 'Failed to fetch') {
|
||||
alert('Failed to connect to stream. If this is a CORS or mixed content issue, try using ElectronCapture: https://electroncapture.app');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Stream error:', error);
|
||||
alert('Failed to connect to stream. Please check the URL and try again.');
|
||||
} finally {
|
||||
stopStreaming();
|
||||
}
|
||||
}
|
||||
|
||||
function stopStreaming() {
|
||||
streamActive = false;
|
||||
isPublishing = false;
|
||||
clearInterval(frameInterval);
|
||||
fpsCounter.textContent = '0';
|
||||
|
||||
if (currentStreamId) {
|
||||
window.electronApi.closeStream(currentStreamId)
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
currentStreamId = null;
|
||||
});
|
||||
}
|
||||
|
||||
if (window.publishingIframe) {
|
||||
window.publishingIframe.remove();
|
||||
window.publishingIframe = null;
|
||||
}
|
||||
|
||||
updateUIState();
|
||||
}
|
||||
|
||||
function showMode(mode) {
|
||||
document.querySelectorAll('.modal-content').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('.modal-buttons button').forEach(btn => btn.classList.remove('active'));
|
||||
document.getElementById(`${mode}-content`).classList.add('active');
|
||||
document.querySelector(`button[onclick="showMode('${mode}')"]`).classList.add('active');
|
||||
|
||||
const descriptions = {
|
||||
'vdo': 'Stream securely peer-to-peer using VDO.Ninja',
|
||||
'whip': 'Stream to any WHIP-compatible service',
|
||||
'twitch': 'Stream directly to your Twitch channel',
|
||||
'preview': 'Local preview mode - no streaming'
|
||||
};
|
||||
document.getElementById('modeDescription').textContent = descriptions[mode];
|
||||
}
|
||||
|
||||
function generatePushId() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
return Array.from({length: 8}, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
}
|
||||
|
||||
function handleTwitch() {
|
||||
const twitchToken = document.getElementById('twitchToken').value;
|
||||
if (!twitchToken) {
|
||||
alert('Twitch Stream Token is required');
|
||||
return;
|
||||
}
|
||||
|
||||
publishConfig = {
|
||||
mode: 'twitch',
|
||||
twitchToken
|
||||
};
|
||||
|
||||
localStorage.setItem('lastTwitchToken', twitchToken);
|
||||
localStorage.setItem('lastMode', 'twitch');
|
||||
|
||||
document.querySelector('.modal-overlay').style.display = 'none';
|
||||
updatePublishInfo();
|
||||
updateUIState();
|
||||
}
|
||||
|
||||
function handleVDO() {
|
||||
const push = document.getElementById('pushId').value || generatePushId();
|
||||
const room = document.getElementById('roomName').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!push && !room) {
|
||||
alert('Either Stream ID or Room Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
publishConfig = {
|
||||
mode: 'vdo',
|
||||
push,
|
||||
room,
|
||||
password
|
||||
};
|
||||
|
||||
const viewUrl = new URL("https://vdo.ninja");
|
||||
if (push) viewUrl.searchParams.set("view", push);
|
||||
if (room) viewUrl.searchParams.set("room", room);
|
||||
if (publishConfig.push && publishConfig.room) viewUrl.searchParams.set("solo", "");
|
||||
if (password) viewUrl.searchParams.set("password", password);
|
||||
viewUrl.searchParams.set("sharperscreen", "");
|
||||
|
||||
document.getElementById('viewLink').value = viewUrl.toString();
|
||||
document.querySelector('.view-link-container').style.display = 'flex';
|
||||
|
||||
localStorage.setItem('lastMode', 'vdo');
|
||||
localStorage.setItem('lastVdoPush', push);
|
||||
localStorage.setItem('lastVdoRoom', room);
|
||||
|
||||
document.querySelector('.modal-overlay').style.display = 'none';
|
||||
updatePublishInfo();
|
||||
updateUIState();
|
||||
}
|
||||
|
||||
function copyViewLink() {
|
||||
const viewLink = document.getElementById('viewLink');
|
||||
if (window.innerWidth <= 1600) {
|
||||
const tempInput = document.createElement('input');
|
||||
tempInput.value = viewLink.value;
|
||||
document.body.appendChild(tempInput);
|
||||
tempInput.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(tempInput);
|
||||
} else {
|
||||
viewLink.select();
|
||||
document.execCommand('copy');
|
||||
}
|
||||
}
|
||||
|
||||
function handleWhip() {
|
||||
const whipUrl = document.getElementById('whipUrl').value;
|
||||
const whipToken = document.getElementById('whipToken').value;
|
||||
|
||||
if (!whipUrl) {
|
||||
alert('WHIP URL is required');
|
||||
return;
|
||||
}
|
||||
|
||||
publishConfig = {
|
||||
mode: 'whip',
|
||||
whipUrl,
|
||||
whipToken
|
||||
};
|
||||
|
||||
localStorage.setItem('lastMode', 'whip');
|
||||
localStorage.setItem('lastWhipUrl', whipUrl);
|
||||
if (whipToken) localStorage.setItem('lastWhipToken', whipToken);
|
||||
|
||||
document.querySelector('.modal-overlay').style.display = 'none';
|
||||
updatePublishInfo();
|
||||
updateUIState();
|
||||
}
|
||||
|
||||
function handlePreview() {
|
||||
publishConfig = {
|
||||
mode: 'preview'
|
||||
};
|
||||
|
||||
localStorage.setItem('lastMode', 'preview');
|
||||
document.querySelector('.modal-overlay').style.display = 'none';
|
||||
updatePublishInfo();
|
||||
updateUIState();
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
previewStreamBtn.addEventListener('click', startStreaming);
|
||||
publishStreamBtn.addEventListener('click', startPublishing);
|
||||
stopStreamBtn.addEventListener('click', stopStreaming);
|
||||
configurePublishingBtn.addEventListener('click', () => {
|
||||
document.querySelector('.modal-overlay').style.display = 'flex';
|
||||
});
|
||||
|
||||
// Handle window unload
|
||||
window.addEventListener('unload', stopStreaming);
|
||||
|
||||
// Initialize on load
|
||||
window.addEventListener('load', () => {
|
||||
// Load saved credentials
|
||||
const lastMode = localStorage.getItem('lastMode') || 'preview';
|
||||
const lastWhipUrl = localStorage.getItem('lastWhipUrl');
|
||||
const lastWhipToken = localStorage.getItem('lastWhipToken');
|
||||
const lastTwitchToken = localStorage.getItem('lastTwitchToken');
|
||||
const lastVdoPush = localStorage.getItem('lastVdoPush');
|
||||
const lastVdoRoom = localStorage.getItem('lastVdoRoom');
|
||||
|
||||
if (lastWhipUrl) document.getElementById('whipUrl').value = lastWhipUrl;
|
||||
if (lastWhipToken) document.getElementById('whipToken').value = lastWhipToken;
|
||||
if (lastTwitchToken) document.getElementById('twitchToken').value = lastTwitchToken;
|
||||
if (lastVdoPush) document.getElementById('pushId').value = lastVdoPush;
|
||||
if (lastVdoRoom) document.getElementById('roomName').value = lastVdoRoom;
|
||||
|
||||
// Show configuration modal if no config exists
|
||||
if (!publishConfig) {
|
||||
document.querySelector('.modal-overlay').style.display = 'flex';
|
||||
showMode(lastMode);
|
||||
}
|
||||
|
||||
// Parse URL parameters if present
|
||||
if (window.location.search) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const cameraSource = params.get('camera') || params.get('source') || params.get('url');
|
||||
|
||||
if (cameraSource) {
|
||||
cameraUrl.value = decodeURIComponent(cameraSource);
|
||||
}
|
||||
}
|
||||
|
||||
updateUIState();
|
||||
checkElectronBanner();
|
||||
});
|
||||
|
||||
let bannerDismissed = false;
|
||||
|
||||
function checkElectronBanner() {
|
||||
const banner = document.querySelector('.electron-banner');
|
||||
const hasElectronApi = window.electronApi && window.electronApi.noCORSFetch;
|
||||
|
||||
if (!bannerDismissed && !hasElectronApi && !streamActive) {
|
||||
banner.style.display = 'flex';
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
bannerDismissed = true;
|
||||
document.querySelector('.electron-banner').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user