mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
925 lines
30 KiB
HTML
925 lines
30 KiB
HTML
<!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> |