Add files via upload

This commit is contained in:
Steve Seguin
2025-05-09 03:02:30 -04:00
committed by GitHub
parent 239a3a6d05
commit 897e20068c
7 changed files with 5184 additions and 0 deletions

View File

@@ -0,0 +1,696 @@
<!DOCTYPE html>
<html>
<head>
<title>Controller Visualizer</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
font-family: Arial, sans-serif;
background: #1a1a1a;
color: #fff;
}
#controls {
margin: 20px;
padding: 10px;
background: #333;
border-radius: 5px;
display: flex;
gap: 10px;
align-items: center;
}
select, button {
padding: 5px;
background: #444;
color: #fff;
border: 1px solid #555;
border-radius: 3px;
}
button {
padding: 5px 10px;
background: #4CAF50;
border: none;
cursor: pointer;
}
.tooltip {
position: relative;
display: inline-block;
}
.tooltip:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 5px 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
}
button:hover { background: #45a049; }
#gamepad { width: 600px; height: 400px; }
.button { transition: fill 0.1s ease; }
.button.pressed { fill: #4CAF50; }
.stick { transition: transform 0.1s ease; }
#deviceLog {
width: 80%;
max-height: 200px;
overflow-y: auto;
background: #333;
padding: 10px;
margin: 10px;
border-radius: 5px;
font-family: monospace;
}
.controller-xbox, .controller-ps { display: none; }
.controller-xbox.active, .controller-ps.active { display: block; }
#mappingSelect { margin-left: 10px; }
.device-section {
background: #444;
padding: 10px;
margin: 5px 0;
border-radius: 5px;
}
.device-section h3 {
margin: 0 0 5px 0;
font-size: 14px;
color: #aaa;
}
.device-section.empty {
color: #888;
font-style: italic;
}
.device-option {
padding: 5px;
margin: 2px 0;
background: #555;
border-radius: 3px;
cursor: pointer;
}
.device-option:hover {
background: #666;
}
.device-option.active {
background: #4CAF50;
}
.device-option {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
margin-left: 8px;
background: #666;
transition: background-color 0.3s ease;
}
.status-indicator.connected {
background: #4CAF50;
}
.status-indicator.disconnected {
background: #f44336;
}
.device-info {
font-size: 12px;
color: #888;
margin-left: 8px;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); }
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
}
.empty small {
display: block;
margin-top: 4px;
color: #666;
}
.controller-xbox, .controller-playstation {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
position: absolute;
}
.controller-xbox.active, .controller-playstation.active {
opacity: 1;
pointer-events: auto;
}
</style>
</head>
<body>
<div id="controls">
<div id="deviceList">
<div id="gamepadSection" class="device-section">
<h3>Game Controllers</h3>
<div id="gamepadDevices">
<div class="empty">
<div>No gamepads detected</div>
<small>Connect an Xbox, PlayStation, or other gamepad and press any button.</small>
</div>
</div>
</div>
<div id="hidSection" class="device-section">
<h3>Other USB Devices</h3>
<div id="hidDevices">
<div class="empty">
<div>No other devices connected</div>
<small>Click "Connect Device" to add USB game controllers.</small>
</div>
</div>
</div>
</div>
<select id="mappingSelect" style="display: none;">
<option value="">Select controller type</option>
<option value="Xbox">Xbox Controller</option>
<option value="PlayStation">PlayStation Controller</option>
</select>
<button id="connectHID" class="tooltip" data-tooltip="Connect a USB game controller">Connect Device</button>
<button id="disconnectDevice" class="tooltip" data-tooltip="Disconnect selected device" style="display: none;">Disconnect</button>
</div>
<div id="deviceLog"></div>
<svg id="gamepad" viewBox="0 0 800 500">
<g class="controller-xbox active">
<path d="M150 100 Q 300 150, 400 100 T 650 100 C 730 100, 730 250, 730 300 C 730 400, 700 450, 650 450 C 600 450, 450 480, 400 480 C 350 480, 200 450, 150 450 C 100 450, 70 400, 70 300 C 70 250, 70 100, 150 100 Z" fill="#333" stroke="#444" stroke-width="2" class="draggable"/>
<path d="M 150 100 C 70 100, 40 130, 40 200 C 40 300, 70 350, 150 400" fill="#333" stroke="#222" stroke-width="3" class="draggable"/>
<path d="M 650 100 C 730 100, 760 130, 760 200 C 760 300, 730 350, 650 400" fill="#333" stroke="#222" stroke-width="3" class="draggable"/>
<g id="xbox-dpad" transform="translate(250,330)">
<path id="dpad-disc" d="M -50 -50 L -50 50 L 50 50 L 50 -50 Z" fill="none" class="draggable"/>
<path d="M -30 -10 L -10 -10 L -10 -30 L 10 -30 L 10 -10 L 30 -10 L 30 10 L 10 10 L 10 30 L -10 30 L -10 10 L -30 10 Z" fill="#222" stroke="#111" stroke-width="2" class="draggable"/>
<rect id="dpad-up" class="button draggable" x="-15" y="-45" width="30" height="20" rx="3" fill="none" stroke="none"/>
<rect id="dpad-down" class="button draggable" x="-15" y="25" width="30" height="20" rx="3" fill="none" stroke="none"/>
<rect id="dpad-left" class="button draggable" x="-45" y="-15" width="20" height="30" rx="3" fill="none" stroke="none"/>
<rect id="dpad-right" class="button draggable" x="25" y="-15" width="20" height="30" rx="3" fill="none" stroke="none"/>
</g>
<g id="xbox-face" transform="translate(550,240)">
<circle id="button-a" class="button draggable" cx="0" cy="60" r="25" fill="#0a0" stroke="#070" stroke-width="2"/>
<circle id="button-b" class="button draggable" cx="60" cy="0" r="25" fill="#a00" stroke="#700" stroke-width="2"/>
<circle id="button-x" class="button draggable" cx="-60" cy="0" r="25" fill="#00a" stroke="#007" stroke-width="2"/>
<circle id="button-y" class="button draggable" cx="0" cy="-60" r="25" fill="#aa0" stroke="#770" stroke-width="2"/>
<text x="0" y="67" fill="#fff" text-anchor="middle" font-size="22" font-weight="bold" font-family="Arial" class="draggable">A</text>
<text x="60" y="7" fill="#fff" text-anchor="middle" font-size="22" font-weight="bold" font-family="Arial" class="draggable">B</text>
<text x="-60" y="7" fill="#fff" text-anchor="middle" font-size="22" font-weight="bold" font-family="Arial" class="draggable">X</text>
<text x="0" y="-53" fill="#fff" text-anchor="middle" font-size="22" font-weight="bold" font-family="Arial" class="draggable">Y</text>
</g>
<circle cx="148.6431121826172" cy="231.15162658691406" r="40" fill="#222" stroke="#111" stroke-width="2" class="draggable"/>
<circle cx="441.5507507324219" cy="328.3466491699219" r="40" fill="#222" stroke="#111" stroke-width="2" class="draggable"/>
<g id="xbox-sticks">
<circle id="stick-left" class="stick draggable" cx="149.55532836914062" cy="232.06381225585938" r="30" fill="#666" stroke="#444" stroke-width="3"/>
<circle id="stick-right" class="stick draggable" cx="442.46295166015625" cy="329.25885009765625" r="30" fill="#666" stroke="#444" stroke-width="3"/>
</g>
<g id="xbox-shoulders">
<rect id="button-lb" class="button draggable" x="95.26795959472656" y="90.06841278076172" width="100" height="25" rx="5" ry="15" fill="#444" stroke="#222" stroke-width="2"/>
<rect id="button-rb" class="button draggable" x="571.892822265625" y="81.85861206054688" width="100" height="25" rx="5" ry="15" fill="#444" stroke="#222" stroke-width="2"/>
</g>
<circle cx="349.8289794921875" cy="183.11287689208984" r="25" fill="#107c10" stroke="#fff" stroke-width="3" class="draggable"/>
<text x="348.00457763671875" y="193.84947967529297" fill="#fff" text-anchor="middle" font-size="32" font-weight="bold" font-family="Arial" class="draggable">X</text>
</g>
<g class="controller-playstation">
<!-- PS Base -->
<path d="M150 100 C150 50, 650 50, 650 100 L650 400 C650 450, 150 450, 150 400 Z" fill="#333" stroke="#444" stroke-width="2" class="draggable"/>
<!-- D-pad -->
<g id="ps-dpad" transform="translate(230,220)">
<!-- Keep the existing paths but update positions to match Xbox layout -->
<path id="ps-dpad-up" class="button draggable" d="M-15 -45 L15 -45 L15 -25 L-15 -25 Z" fill="#666"/>
<path id="ps-dpad-right" class="button draggable" d="M25 -15 L45 -15 L45 15 L25 15 Z" fill="#666"/>
<path id="ps-dpad-down" class="button draggable" d="M-15 25 L15 25 L15 45 L-15 45 Z" fill="#666"/>
<path id="ps-dpad-left" class="button draggable" d="M-45 -15 L-25 -15 L-25 15 L-45 15 Z" fill="#666"/>
</g>
<!-- Face buttons -->
<g id="ps-face" transform="translate(540,230)">
<!-- Update circle positions to match Xbox spacing -->
<circle id="ps-cross" class="button draggable" cx="0" cy="60" r="25" fill="#666"/>
<circle id="ps-circle" class="button draggable" cx="60" cy="0" r="25" fill="#666"/>
<circle id="ps-square" class="button draggable" cx="-60" cy="0" r="25" fill="#666"/>
<circle id="ps-triangle" class="button draggable" cx="0" cy="-60" r="25" fill="#666"/>
<!-- Update symbol positions to match new circle positions -->
<path d="M-8 60 L8 60 M0 52 L0 68" stroke="#444" stroke-width="3" class="draggable"/>
<circle cx="60" cy="0" r="15" stroke="#444" stroke-width="3" fill="none" class="draggable"/>
<rect x="-70" y="-10" width="20" height="20" stroke="#444" stroke-width="3" fill="none" class="draggable"/>
<path d="M0 -68 L-8 -52 L8 -52 Z" fill="#444" class="draggable"/>
</g>
<!-- Sticks base rings -->
<circle cx="297.3318176269531" cy="348.8255157470703" r="40" fill="#444" stroke="#555" stroke-width="2" class="draggable"/>
<circle cx="462.5313720703125" cy="356.6248779296875" r="40" fill="#444" stroke="#555" stroke-width="2" class="draggable"/>
<!-- Sticks -->
<g id="ps-sticks">
<circle id="ps-stick-left" class="stick draggable" cx="297.3318176269531" cy="348.82554626464844" r="30" fill="#888"/>
<circle id="ps-stick-right" class="stick draggable" cx="460.70697021484375" cy="357.53704833984375" r="30" fill="#888"/>
</g>
<!-- Shoulder buttons -->
<g id="ps-shoulders">
<rect id="ps-l1" class="button draggable" x="200" y="50" width="80" height="30" rx="15" fill="#666"/>
<rect id="ps-r1" class="button draggable" x="520" y="50" width="80" height="30" rx="15" fill="#666"/>
</g>
</g>
</svg>
<script>
const deviceSelect = document.getElementById('deviceSelect');
const connectButton = document.getElementById('connectHID');
const disconnectButton = document.getElementById('disconnectDevice');
const deviceLog = document.getElementById('deviceLog');
let animationFrame;
const devices = new Map();
let lastState = new Uint8Array();
let selectedDeviceId = null;
const mappingSelect = document.getElementById('mappingSelect');
let currentMapping = null;
function logDevice(message) {
const line = document.createElement('div');
line.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
deviceLog.insertBefore(line, deviceLog.firstChild);
if (deviceLog.children.length > 50) deviceLog.lastChild.remove();
}
function updateDeviceOption(deviceId, deviceInfo) {
const deviceEl = document.createElement('div');
deviceEl.className = 'device-option';
const contentWrapper = document.createElement('div');
contentWrapper.style.flex = 1;
const nameEl = document.createElement('div');
nameEl.textContent = deviceId;
contentWrapper.appendChild(nameEl);
if (deviceInfo.detectedType) {
const typeEl = document.createElement('div');
typeEl.className = 'device-info';
typeEl.textContent = `Type: ${deviceInfo.detectedType}`;
contentWrapper.appendChild(typeEl);
}
deviceEl.appendChild(contentWrapper);
const indicator = document.createElement('div');
indicator.className = 'status-indicator';
if (deviceInfo.type === 'gamepad') {
const gamepad = navigator.getGamepads()[deviceInfo.device.index];
indicator.classList.toggle('connected', gamepad && gamepad.connected);
} else {
indicator.classList.toggle('connected', deviceInfo.device.opened);
}
deviceEl.appendChild(indicator);
deviceEl.onclick = () => selectDevice(deviceId);
return deviceEl;
}
function handleHIDInput(e) {
const data = new Uint8Array(e.data.buffer);
if (lastState.length) {
for (let i = 0; i < data.length; i++) {
if (data[i] !== lastState[i]) {
logDevice(`Device ${e.device.productName} Button ${i + 1} ${data[i] ? "Pressed" : "Released"}`);
const buttonMap = {
0: 'button-x',
1: 'button-y',
2: 'button-b',
3: 'button-a',
4: 'dpad-up',
5: 'dpad-right',
6: 'dpad-down',
7: 'dpad-left'
};
const elementId = buttonMap[i];
if (elementId) {
const element = document.getElementById(elementId);
if (element) {
element.classList.toggle('pressed', Boolean(data[i]));
}
}
}
}
}
lastState = data;
}
async function setupHIDDevice(device) {
try {
if (!device.opened) {
await device.open();
}
addDevice(device.productName, device, 'hid');
device.addEventListener("inputreport", handleHIDInput);
logDevice(`Connected to ${device.productName}`);
disconnectButton.style.display = 'inline-block';
} catch (error) {
logDevice(`Error setting up device: ${error.message}`);
}
}
connectButton.onclick = async () => {
try {
const devices = await navigator.hid.requestDevice({
filters: [] // Let Chrome show all HID devices
});
for (const device of devices) {
await setupHIDDevice(device);
}
} catch (error) {
logDevice(`Error connecting device: ${error.message}`);
}
};
const controllerMappings = {
'Xbox': {
type: 'gamepad',
buttons: {
0: 'button-a',
1: 'button-b',
2: 'button-x',
3: 'button-y',
4: 'button-lb',
5: 'button-rb',
12: 'dpad-up',
13: 'dpad-down',
14: 'dpad-left',
15: 'dpad-right'
},
sticks: {
leftX: 0,
leftY: 1,
rightX: 2,
rightY: 3
},
detect: (id) => /xbox|xinput/i.test(id)
},
'PlayStation': {
type: 'gamepad',
buttons: {
0: 'ps-cross',
1: 'ps-circle',
2: 'ps-square',
3: 'ps-triangle',
4: 'ps-l1',
5: 'ps-r1',
12: 'ps-dpad-up',
13: 'ps-dpad-down',
14: 'ps-dpad-left',
15: 'ps-dpad-right'
},
sticks: {
leftX: 0,
leftY: 1,
rightX: 2,
rightY: 3
},
detect: (id) => /playstation|ps4|ps5|dualshock|dualsense/i.test(id)
}
};
function setControllerType(type) {
const xboxController = document.querySelector('.controller-xbox');
const psController = document.querySelector('.controller-playstation');
if (!xboxController || !psController) return;
xboxController.classList.remove('active');
psController.classList.remove('active');
if (type) {
if (type.toLowerCase() === 'xbox') {
xboxController.classList.add('active');
} else if (type.toLowerCase() === 'playstation') {
psController.classList.add('active');
}
currentMapping = controllerMappings[type];
mappingSelect.value = type;
}
}
function detectControllerType(id) {
for (const [type, mapping] of Object.entries(controllerMappings)) {
if (mapping.detect(id)) return type;
}
return null;
}
function addDevice(id, device, type) {
const detectedType = detectControllerType(id);
devices.set(id, { device, type, detectedType });
const gamepadDevices = document.getElementById('gamepadDevices');
const hidDevices = document.getElementById('hidDevices');
gamepadDevices.innerHTML = '';
hidDevices.innerHTML = '';
let hasGamepads = false;
let hasHID = false;
// Sort and group devices
const deviceArray = Array.from(devices.entries()).sort((a, b) => {
if (a[1].type !== b[1].type) return a[1].type === 'gamepad' ? -1 : 1;
if (!!a[1].detectedType !== !!b[1].detectedType) return a[1].detectedType ? -1 : 1;
return a[0].localeCompare(b[0]);
});
deviceArray.forEach(([deviceId, deviceInfo]) => {
const deviceEl = updateDeviceOption(deviceId, deviceInfo);
if (deviceInfo.type === 'gamepad') {
hasGamepads = true;
gamepadDevices.appendChild(deviceEl);
} else {
hasHID = true;
hidDevices.appendChild(deviceEl);
}
});
// Update empty states
if (!hasGamepads) {
gamepadDevices.innerHTML = `
<div class="empty">
No gamepads detected. Connect a controller and press any button.
<br><small>Supports Xbox, PlayStation, and other standard gamepads.</small>
</div>`;
}
if (!hasHID) {
hidDevices.innerHTML = `
<div class="empty">
No other devices connected.
<br><small>Click "Connect Device" to add USB game controllers.</small>
</div>`;
}
if (!selectedDeviceId && deviceArray.length > 0) {
const firstGamepad = deviceArray.find(([_, info]) => info.type === 'gamepad');
if (firstGamepad) selectDevice(firstGamepad[0]);
}
updateConnectButton();
}
function updateButtonVisuals(elementId, isPressed, value = 1) {
const element = document.getElementById(elementId);
if (!element) return;
element.classList.toggle('pressed', isPressed);
if (isPressed) {
// Add visual pressure feedback
const intensity = Math.min(value * 255, 255);
element.style.fill = `rgb(${76 + intensity * 0.2}, ${175 + intensity * 0.1}, ${80 + intensity * 0.1})`;
} else {
element.style.fill = '#666';
}
}
function updateConnectButton() {
const hasDevices = devices.size > 0;
const anyHIDDevices = Array.from(devices.values()).some(d => d.type === 'hid');
connectButton.classList.toggle('pulse', !hasDevices);
connectButton.textContent = hasDevices ? 'Add Device' : 'Connect Device';
if (anyHIDDevices) {
disconnectButton.style.display = 'inline-block';
} else {
disconnectButton.style.display = 'none';
}
}
function selectDevice(deviceId) {
const deviceInfo = devices.get(deviceId);
if (!deviceInfo) return;
selectedDeviceId = deviceId;
// Update UI
document.querySelectorAll('.device-option').forEach(el =>
el.classList.toggle('active', el.textContent === deviceId)
);
if (deviceInfo.detectedType) {
setControllerType(deviceInfo.detectedType);
mappingSelect.style.display = 'none';
} else {
mappingSelect.style.display = 'inline-block';
}
startPolling();
}
function removeDevice(id) {
devices.delete(id);
if (selectedDeviceId === id) {
selectedDeviceId = null;
stopPolling();
}
}
window.addEventListener('gamepadconnected', (e) => {
logDevice(`Gamepad connected: ${e.gamepad.id}`);
addDevice(e.gamepad.id, e.gamepad, 'gamepad');
});
window.addEventListener('gamepaddisconnected', (e) => {
logDevice(`Gamepad disconnected: ${e.gamepad.id}`);
removeDevice(e.gamepad.id);
});
disconnectButton.onclick = async () => {
const deviceInfo = devices.get(selectedDeviceId);
if (deviceInfo && deviceInfo.type === 'hid') {
await deviceInfo.device.close();
removeDevice(selectedDeviceId);
disconnectButton.style.display = 'none';
logDevice(`Disconnected ${selectedDeviceId}`);
}
};
function startPolling() {
if (!animationFrame) {
animationFrame = requestAnimationFrame(updateVisuals);
}
}
function stopPolling() {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
resetVisuals();
}
function updateVisuals() {
const deviceInfo = devices.get(selectedDeviceId);
if (deviceInfo && deviceInfo.type === 'gamepad') {
updateGamepadVisuals(deviceInfo.device);
}
animationFrame = requestAnimationFrame(updateVisuals);
}
mappingSelect.addEventListener('change', () => {
if (mappingSelect.value) {
setControllerType(mappingSelect.value);
}
});
function updateGamepadVisuals(gamepadId) {
const gamepad = navigator.getGamepads()[gamepadId.index];
if (!gamepad || !currentMapping) return;
gamepad.buttons.forEach((button, index) => {
const elementId = currentMapping.buttons[index];
if (elementId) {
updateButtonVisuals(elementId, button.pressed, button.value);
}
});
const stickIds = currentMapping === controllerMappings['PlayStation'] ?
['ps-stick-left', 'ps-stick-right'] :
['stick-left', 'stick-right'];
updateStick(stickIds[0],
gamepad.axes[currentMapping.sticks.leftX],
gamepad.axes[currentMapping.sticks.leftY]
);
updateStick(stickIds[1],
gamepad.axes[currentMapping.sticks.rightX],
gamepad.axes[currentMapping.sticks.rightY]
);
}
function updateStick(stickId, x, y) {
const stick = document.getElementById(stickId);
const maxOffset = 20;
const transformX = x * maxOffset;
const transformY = y * maxOffset;
stick.style.transform = `translate(${transformX}px, ${transformY}px)`;
}
function resetVisuals() {
document.querySelectorAll('.button').forEach(button => {
button.classList.remove('pressed');
});
document.querySelectorAll('.stick').forEach(stick => {
stick.style.transform = 'translate(0, 0)';
});
}
// Check for any already-connected HID devices
async function initializeHID() {
const devices = await navigator.hid.getDevices();
devices.forEach(device => {
logDevice(`Found HID: ${device.productName}`);
if (device.opened) {
setupHIDDevice(device);
}
});
}
document.addEventListener('DOMContentLoaded', initializeHID);
</script>
</body>
</html>

964
examples/googleai.html Normal file
View File

@@ -0,0 +1,964 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemini Vision Chat - Live AI Video Conversations</title>
<meta name="description" content="Experience real-time AI video conversations with Google's Gemini Vision AI. This interactive demo showcases live video analysis and natural language processing capabilities.">
<meta name="keywords" content="Gemini AI, video chat, AI assistant, Google AI, computer vision, real-time AI">
<meta name="robots" content="index, follow">
<meta property="og:title" content="Gemini Vision Chat">
<meta property="og:description" content="Live video conversations with Google's Gemini Vision AI">
<meta property="og:type" content="website">
<meta name="author" content="Steve Seguin">
<link rel="me" href="https://github.com/steveseguin">
<meta property="article:author" content="https://github.com/steveseguin">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnMSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIxMDAlIiB5Mj0iMTAwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3R5bGU9InN0b3AtY29sb3I6IzQwNEVFRCIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6IzU4NjVGMiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxwYXRoIGQ9Ik04IDhoNDh2MzhIMjJMOCA1NlY4eiIgZmlsbD0idXJsKCNnMSkiLz48cGF0aCBkPSJNMjAgMjhoMjRNMjAgMjBoMjRNMjAgMzZoMTYiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48Y2lyY2xlIGN4PSI0OCIgY3k9IjM2IiByPSIzIiBmaWxsPSIjZmZmIi8+PC9zdmc+">
<style>
body {
margin: 0;
padding: 20px;
display: flex;
height: 100vh;
box-sizing: border-box;
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
position: relative;
}
.github-link {
position: fixed;
bottom: 15px;
left: 15px;
opacity: 0.7;
transition: opacity 0.2s;
}
.github-link:hover {
opacity: 1;
}
p {
display: inline-block;
}
.left-panel {
width: 50%;
padding-right: 20px;
}
.right-panel {
width: 50%;
display: flex;
flex-direction: column;
height: 100%;
}
.controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.preview {
width: 100%;
max-height: calc(100vh - 200px);
object-fit: contain;
border-radius: 12px;
background: #2a2a2a;
}
#error {
color: #ff6b6b;
margin: 10px 0;
}
select, button, .api-key, .message-input {
background: #2a2a2a;
border: 1px solid #404040;
color: #e0e0e0;
padding: 8px 12px;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease;
}
select:hover, button:hover {
background: #333;
border-color: #505050;
}
button {
cursor: pointer;
background: #404eed;
border: none;
font-weight: 500;
}
button:hover {
background: #5865f2;
}
#startButton {
background: #22c55e;
font-size: 16px;
padding: 10px 20px;
font-weight: 600;
animation: pulse 2s infinite;
}
#startButton:hover {
background: #16a34a;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.api-key.highlight {
border-color: #ff6b6b;
outline: none;
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.3);
}
.api-key-container {
display: flex;
flex-direction: row;
gap: 8px;
}
.api-key-info {
font-size: 13px;
color: #a0a0a0;
margin: auto;
}
.api-key-info a {
color: #5865f2;
text-decoration: none;
}
.api-key-info a:hover {
text-decoration: underline;
}
#startButton:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #2a2a2a;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
background: #2a2a2a;
border-radius: 12px;
overflow: hidden;
}
.responses {
flex-grow: 1;
padding: 16px;
background: #2a2a2a;
overflow-y: auto;
margin-bottom: 10px;
}
.input-container {
display: flex;
gap: 10px;
padding: 16px;
background: #232323;
border-top: 1px solid #404040;
}
.message {
margin: 8px 0;
padding: 12px;
border-radius: 8px;
line-height: 1.5;
}
.user-message {
background: #404eed;
margin-left: 20px;
color: #fff;
}
.assistant-message {
background: #333;
margin-right: 20px;
}
.markdown-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.markdown-content li {
margin-left: 20px;
margin-bottom: 5px;
}
.markdown-content code {
background: #232323;
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, monospace;
font-size: 0.9em;
}
.responses::-webkit-scrollbar {
width: 8px;
}
.responses::-webkit-scrollbar-track {
background: #232323;
border-radius: 4px;
}
.responses::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
.responses::-webkit-scrollbar-thumb:hover {
background: #505050;
}
</style>
</head>
<body>
<div class="left-panel">
<div class="controls">
<select id="videoSource"></select>
<select id="audioSource"></select>
<button id="startButton">Start Stream</button>
<select id="responseType">
<option value="text">Text Response</option>
<option value="audio">Audio Response</option>
</select>
<select id="voiceSelect" style="display: none;">
<option value="Aoede">Female Voice 1 (Aoede)</option>
<option value="Kore">Female Voice 2 (Kore)</option>
<option value="Puck">Male Voice 1 (Puck)</option>
<option value="Charon">Male Voice 2 (Charon)</option>
<option value="Fenrir">Male Voice 3 (Fenrir)</option>
</select>
<div class="api-key-container">
<input type="password" id="apiKey" placeholder="Enter Gemini API Key" size="15" class="api-key">
<div class="api-key-info">
Get your free Gemini API key at <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener">Google AI Studio</a>.
</div>
</div> </div>
<div id="error"></div>
<video class="preview" id="preview" autoplay muted></video>
</div>
<div class="right-panel">
<div class="chat-container">
<div id="responses" class="responses"></div>
<div class="input-container">
<input type="text" class="message-input" placeholder="Type a message...">
<button id="sendButton">Send</button>
</div>
</div>
</div>
<a href="https://github.com/steveseguin/gemini-chatbot" class="github-link" target="_blank" rel="noopener noreferrer" title="Fork on GitHub (MIT License)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="#e0e0e0">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
<script>
class GoogleLivePublisher {
constructor(stream, apiKey) {
this.stream = stream;
this.apiKey = apiKey;
this.ws = null;
this.audioContext = null;
this.videoProcessor = null;
this.canvasContext = null;
this.lastImageTime = 0;
this.imageInterval = 200;
this.imageWidth = 640;
this.imageHeight = 360;
this.handleMessage = this.handleMessage.bind(this);
this.audioPlayer = new AudioPlayer();
}
async handleMessage(event) {
try {
let response;
if (event.data instanceof Blob) {
const text = await event.data.text();
response = JSON.parse(text);
} else {
response = JSON.parse(event.data);
}
if (response.setupComplete) {
console.log('Setup complete received');
this.sendPrompt("Hi, introduce yourself in a sentence for me. Be friendly to me.");
}
if (response.serverContent?.modelTurn?.parts) {
const parts = response.serverContent.modelTurn.parts;
let hasAudioParts = false;
parts.forEach(part => {
if (part.text) {
console.log('Model response:', part.text);
const event = new CustomEvent('modelResponse', {
detail: {
text: part.text
}
});
window.dispatchEvent(event);
}
if (part.inlineData && part.inlineData.mimeType.startsWith('audio/')) {
hasAudioParts = true;
console.log('Received audio response with mime type:', part.inlineData.mimeType);
try {
const rateMatch = part.inlineData.mimeType.match(/rate=(\d+)/);
const sampleRate = rateMatch ? parseInt(rateMatch[1]) : 24000;
this.audioPlayer.resume();
const audioData = base64ToArrayBuffer(part.inlineData.data);
console.log('Processing audio chunk of size:', audioData.byteLength);
this.audioPlayer.addPCM16(new Uint8Array(audioData));
} catch (err) {
console.error('Error processing audio:', err);
}
}
});
if (response.serverContent.turnComplete && hasAudioParts) {
console.log('Turn complete, finalizing audio');
this.audioPlayer.complete();
}
}
if (!response.setupComplete && !response.serverContent) {
console.log('Other response type:', response);
}
} catch (err) {
console.error('Error handling message:', err);
}
}
sendPrompt(text) {
if (!this.isConnected()) {
console.error('WebSocket not connected, attempting reconnect...');
this.connect().then(() => {
this._sendPromptInternal(text);
});
return;
}
this._sendPromptInternal(text);
}
_sendPromptInternal(text) {
if (this.isConnected()) {
const message = {
clientContent: {
turns: [{
role: "user",
parts: [{
text
}]
}],
turnComplete: true
}
};
console.log('Sending prompt:', message);
this.ws.send(JSON.stringify(message));
} else {
console.error('WebSocket still not ready after reconnect attempt');
}
}
sendMediaChunk(mediaChunks) {
if (this.ws?.readyState === WebSocket.OPEN) {
const message = {
realtimeInput: {
mediaChunks: mediaChunks.map(chunk => ({
mimeType: chunk.inlineData.mimeType,
data: chunk.inlineData.data
}))
}
};
this.ws.send(JSON.stringify(message));
}
}
isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
async connect() {
const host = 'generativelanguage.googleapis.com';
const uri = `wss://${host}/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=${this.apiKey}`;
if (this.isConnected()) {
console.log('Already connected');
return;
}
const responseType = document.getElementById('responseType');
const voiceSelect = document.getElementById('voiceSelect');
voiceSelect.style.display = responseType.value === 'audio' ? 'block' : 'none';
this.ws = new WebSocket(uri);
this.ws.onmessage = this.handleMessage;
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
};
await new Promise((resolve, reject) => {
this.ws.addEventListener('open', resolve, {
once: true
});
this.ws.addEventListener('error', reject, {
once: true
});
});
const setupMessage = {
setup: {
model: "models/gemini-2.0-flash-exp",
systemInstruction: {
parts: [{
text: "You are a friendly and helpful social chat assistant that can see and hear the user."
}]
},
generationConfig: {
temperature: 0.9,
topK: 1,
topP: 1,
candidateCount: 1,
responseModalities: responseType.value === 'audio' ? 'AUDIO' : 'TEXT',
...(responseType.value === 'audio' && {
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: voiceSelect.value
}
}
}
})
}
}
};
console.log('Sending setup message:', setupMessage);
this.ws.send(JSON.stringify(setupMessage));
}
async start() {
try {
await this.connect();
await this.setupAudioProcessing();
this.setupVideoProcessing();
} catch (err) {
console.error('Failed to start:', err);
this.stop();
throw err;
}
}
async setupAudioProcessing() {
this.audioContext = new AudioContext({
sampleRate: 16000
});
const workletBlob = new Blob([`registerProcessor('audio-processor', ${AudioProcessingWorklet})`], {
type: 'application/javascript'
});
const workletUrl = URL.createObjectURL(workletBlob);
await this.audioContext.audioWorklet.addModule(workletUrl);
URL.revokeObjectURL(workletUrl);
const source = this.audioContext.createMediaStreamSource(this.stream);
const processor = new AudioWorkletNode(this.audioContext, 'audio-processor');
processor.port.onmessage = (event) => {
if (event.data.data?.int16arrayBuffer) {
const base64Audio = btoa(String.fromCharCode(...new Uint8Array(event.data.data.int16arrayBuffer)));
this.sendMediaChunk([{
mime_type: "audio/pcm;rate=16000",
data: base64Audio
}]);
}
};
source.connect(processor);
}
setupVideoProcessing() {
const canvas = document.createElement('canvas');
canvas.width = this.imageWidth;
canvas.height = this.imageHeight;
this.canvasContext = canvas.getContext('2d');
const videoTrack = this.stream.getVideoTracks()[0];
const videoElement = document.createElement('video');
videoElement.srcObject = new MediaStream([videoTrack]);
videoElement.autoplay = true;
const captureFrame = () => {
const now = Date.now();
if (now - this.lastImageTime >= this.imageInterval) {
this.canvasContext.drawImage(videoElement, 0, 0, this.imageWidth, this.imageHeight);
const base64Image = canvas.toDataURL('image/jpeg', 0.8).split(',')[1];
this.sendMediaChunk([{
mime_type: "image/jpeg",
data: base64Image
}]);
this.lastImageTime = now;
}
if (!this.stopped) {
requestAnimationFrame(captureFrame);
}
};
videoElement.addEventListener('loadedmetadata', () => {
requestAnimationFrame(captureFrame);
});
}
sendMediaChunk(mediaChunks) {
if (this.ws?.readyState === WebSocket.OPEN) {
const message = {
realtimeInput: {
mediaChunks
}
};
this.ws.send(JSON.stringify(message));
}
}
stop() {
this.stopped = true;
this.ws?.close();
this.audioContext?.close();
this.audioPlayer?.stop();
this.ws = null;
this.audioContext = null;
this.videoProcessor = null;
this.canvasContext = null;
}
}
class AudioPlayer {
constructor() {
this.context = new AudioContext();
this.gainNode = this.context.createGain();
this.gainNode.connect(this.context.destination);
this.gainNode.gain.value = 1;
this.bufferSize = 8192 * 4;
this.sampleRate = 24000;
this.processingBuffer = new Float32Array(0);
this.audioQueue = [];
this.isPlaying = false;
this.scheduledTime = 0;
this.currentSource = null;
this.silencePadding = 0.015;
this.startDelay = 0.05;
this.bufferTarget = 3;
this.scheduleAheadTime = 0.2;
this.minimumBufferSize = this.bufferSize;
this.underrunRecoveryTime = 0.2;
this.maxBufferSize = this.bufferSize * 8;
this.isPaused = false;
this.lastPlaybackTime = 0;
this.totalScheduledDuration = 0;
this.underrunCount = 0;
this.lastUnderrunTime = 0;
this.adaptiveBufferTarget = this.bufferTarget;
}
addPCM16(chunk) {
const float32Array = new Float32Array(chunk.length / 2);
const dataView = new DataView(chunk.buffer);
for (let i = 0; i < chunk.length / 2; i++) {
float32Array[i] = dataView.getInt16(i * 2, true) / 32768;
}
const newBuffer = new Float32Array(this.processingBuffer.length + float32Array.length);
newBuffer.set(this.processingBuffer);
newBuffer.set(float32Array, this.processingBuffer.length);
this.processingBuffer = newBuffer;
if (this.processingBuffer.length >= this.minimumBufferSize) {
const paddedBuffer = this.addSilencePadding(this.processingBuffer);
this.audioQueue.push(paddedBuffer);
this.processingBuffer = new Float32Array(0);
if (!this.isPlaying && this.audioQueue.length >= this.adaptiveBufferTarget) {
this.isPlaying = true;
this.scheduledTime = this.context.currentTime + (this.initialChunk ? this.startDelay : 0);
this.initialChunk = false;
this.scheduleNextBuffer();
}
}
}
addSilencePadding(audioData) {
const paddingSamples = Math.floor(this.silencePadding * this.sampleRate);
const crossfadeSamples = Math.min(paddingSamples, Math.floor(this.sampleRate * 0.015));
const paddedBuffer = new Float32Array(audioData.length + (paddingSamples * 2));
paddedBuffer.set(audioData, paddingSamples);
for (let i = 0; i < crossfadeSamples; i++) {
const fadeIn = 0.5 * (1 - Math.cos((i / crossfadeSamples) * Math.PI));
paddedBuffer[paddingSamples + i] *= fadeIn;
}
for (let i = 0; i < crossfadeSamples; i++) {
const fadeOut = 0.5 * (1 + Math.cos((i / crossfadeSamples) * Math.PI));
paddedBuffer[paddingSamples + audioData.length - crossfadeSamples + i] *= fadeOut;
}
return paddedBuffer;
}
scheduleNextBuffer() {
if (!this.isPlaying || this.isPaused) return;
const now = this.context.currentTime;
const buffersNeeded = Math.max(0, this.adaptiveBufferTarget - this.audioQueue.length);
if (this.audioQueue.length === 0) {
this.underrunCount++;
this.lastUnderrunTime = Date.now();
this.isPaused = true;
this.lastPlaybackTime = this.scheduledTime;
return;
}
while (this.audioQueue.length > 0 &&
this.scheduledTime < now + this.scheduleAheadTime) {
const audioData = this.audioQueue.shift();
const audioBuffer = this.createAudioBuffer(audioData);
const source = this.context.createBufferSource();
source.buffer = audioBuffer;
const startTime = Math.max(this.scheduledTime, now);
source.connect(this.gainNode);
const scheduleOffset = 0.005;
source.start(startTime + scheduleOffset);
this.currentSource = source;
this.scheduledTime = startTime + audioBuffer.duration - this.silencePadding;
source.onended = () => {
if (this.audioQueue.length > 0) {
requestAnimationFrame(() => this.scheduleNextBuffer());
}
};
}
if (this.isPlaying && !this.isPaused) {
const nextCheckDelay = Math.max(10,
(this.scheduledTime - this.context.currentTime) * 500
);
setTimeout(() => this.scheduleNextBuffer(), nextCheckDelay);
}
}
createAudioBuffer(audioData) {
const audioBuffer = this.context.createBuffer(1, audioData.length, this.sampleRate);
audioBuffer.getChannelData(0).set(audioData);
return audioBuffer;
}
stop() {
this.complete();
setTimeout(() => {
this.isPlaying = false;
this.isPaused = false;
if (this.currentSource) {
try {
this.currentSource.stop();
} catch (e) {
console.warn('Error stopping current source:', e);
}
this.currentSource = null;
}
this.audioQueue = [];
this.processingBuffer = new Float32Array(0);
this.underrunCount = 0;
this.lastUnderrunTime = 0;
this.adaptiveBufferTarget = this.bufferTarget;
this.initialChunk = true;
this.totalScheduledDuration = 0;
this.lastPlaybackTime = 0;
const currentTime = this.context.currentTime;
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, currentTime);
this.gainNode.gain.linearRampToValueAtTime(0, currentTime + 0.2);
setTimeout(() => {
this.gainNode.disconnect();
this.gainNode = this.context.createGain();
this.gainNode.connect(this.context.destination);
}, 300);
}, 500);
}
complete() {
if (this.processingBuffer.length > 0) {
const paddedBuffer = this.addSilencePadding(this.processingBuffer);
this.audioQueue.push(paddedBuffer);
this.processingBuffer = new Float32Array(0);
}
const endingSilence = new Float32Array(Math.floor(this.sampleRate * 0.2));
this.audioQueue.push(endingSilence);
if (this.isPlaying) {
this.scheduleNextBuffer();
} else if (this.audioQueue.length > 0) {
this.isPlaying = true;
this.scheduledTime = this.context.currentTime + 0.05;
this.scheduleNextBuffer();
}
}
async resume() {
if (this.context.state === "suspended") {
await this.context.resume();
}
this.gainNode.gain.setValueAtTime(0, this.context.currentTime);
this.gainNode.gain.linearRampToValueAtTime(1, this.context.currentTime + 0.1);
}
}
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
class MessageFormatter {
constructor() {
this.currentMessage = '';
this.currentMessageElement = null;
this.messageBuffer = '';
this.messageComplete = false;
this.lastMessageTime = Date.now();
this.pauseThreshold = 300;
}
formatMarkdown(text) {
let formatted = text
.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>');
const lines = formatted.split('\n');
const formattedLines = lines.map(line => {
if (line.trim().startsWith('*') && line.trim()[1] === ' ') {
return `<li>${line.trim().substring(2)}</li>`;
}
if (/^\d+\./.test(line.trim())) {
return `<li>${line.trim()}</li>`;
}
return line;
});
return formattedLines.join('\n')
.replace(/\n\n/g, '<br><br>')
.replace(/\n(?![<])/g, '<br>');
}
appendMessage(text, isUser = false) {
const now = Date.now();
if (isUser) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message user-message';
const contentDiv = document.createElement('div');
contentDiv.className = 'markdown-content';
contentDiv.textContent = text;
messageDiv.appendChild(contentDiv);
responsesDiv.appendChild(messageDiv);
this.messageComplete = true;
this.scrollToBottom();
this.lastMessageTime = now;
return;
}
if (this.currentMessageElement && (now - this.lastMessageTime > this.pauseThreshold)) {
this.messageBuffer += '\n';
}
this.messageBuffer += text;
this.lastMessageTime = now;
if (!this.currentMessageElement) {
this.currentMessageElement = document.createElement('div');
this.currentMessageElement.className = 'message assistant-message';
const contentDiv = document.createElement('div');
contentDiv.className = 'markdown-content';
this.currentMessageElement.appendChild(contentDiv);
responsesDiv.appendChild(this.currentMessageElement);
}
const contentDiv = this.currentMessageElement.querySelector('.markdown-content');
contentDiv.innerHTML = this.formatMarkdown(this.messageBuffer);
if (
this.messageBuffer.match(/\n\n$/) ||
this.messageBuffer.match(/[.!?]\s+$/) ||
this.messageBuffer.match(/\n\s*[-*]\s.*\n\n$/)
) {
this.finalizeMessage();
}
this.scrollToBottom();
}
finalizeMessage() {
this.messageBuffer = '';
this.currentMessageElement = null;
this.messageComplete = true;
this.lastMessageTime = Date.now();
}
scrollToBottom() {
responsesDiv.scrollTop = responsesDiv.scrollHeight;
}
}
const AudioProcessingWorklet = `
class AudioProcessor extends AudioWorkletProcessor {
buffer = new Int16Array(2048);
bufferWriteIndex = 0;
process(inputs) {
if (inputs[0].length) {
const samples = inputs[0][0];
for (let i = 0; i < samples.length; i++) {
const int16Value = samples[i] * 32768;
this.buffer[this.bufferWriteIndex++] = int16Value;
if(this.bufferWriteIndex >= this.buffer.length) {
this.port.postMessage({
data: { int16arrayBuffer: this.buffer.buffer }
});
this.bufferWriteIndex = 0;
}
}
}
return true;
}
}`;
const messageFormatter = new MessageFormatter();
window.addEventListener('modelResponse', (event) => {
console.log(event.detail.text);
messageFormatter.appendMessage(event.detail.text);
});
let stream = null;
const videoSelect = document.getElementById('videoSource');
const audioSelect = document.getElementById('audioSource');
const preview = document.getElementById('preview');
const errorDisplay = document.getElementById('error');
const responsesDiv = document.getElementById('responses');
let publisher = null;
function validateApiKey() {
const apiKey = document.getElementById('apiKey').value.trim();
startButton.disabled = !apiKey;
return apiKey;
}
document.getElementById('apiKey').value = localStorage.getItem('apiKey') || '';
validateApiKey();
document.getElementById('apiKey').addEventListener('input', validateApiKey);
startButton.addEventListener('click', async () => {
const apiKeyInput = document.getElementById('apiKey');
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
apiKeyInput.classList.add('highlight');
setTimeout(() => apiKeyInput.classList.remove('highlight'), 2000);
return;
}
try {
if (publisher) {
startButton.textContent = 'Starting...';
startButton.disabled = true;
publisher.stop();
publisher = null;
preview.srcObject = null;
startButton.textContent = 'Start Stream';
startButton.disabled = false;
return;
}
startButton.textContent = 'Starting...';
startButton.disabled = true;
const stream = await getStream();
preview.srcObject = stream;
localStorage.setItem('apiKey', apiKey);
publisher = new GoogleLivePublisher(stream, apiKey);
await publisher.start();
startButton.textContent = 'Stop Stream';
startButton.disabled = false;
} catch (err) {
console.error(err);
showError('Failed to start publishing: ' + err.message);
startButton.textContent = 'Start Stream';
startButton.disabled = false;
}
});
async function getDevices() {
try {
await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
})
.then(stream => stream.getTracks().forEach(track => track.stop()))
.catch(e => console.warn('Permission denied:', e));
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(d => d.kind === 'videoinput');
const audioDevices = devices.filter(d => d.kind === 'audioinput');
videoDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.deviceId;
option.text = device.label || `Camera ${videoSelect.length + 1}`;
videoSelect.appendChild(option);
});
audioDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.deviceId;
option.text = device.label || `Microphone ${audioSelect.length + 1}`;
audioSelect.appendChild(option);
});
} catch (err) {
showError('Failed to get devices: ' + err.message);
}
}
async function getStream() {
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
const constraints = {
video: {
deviceId: videoSelect.value ? {
exact: videoSelect.value
} : undefined
},
audio: {
deviceId: audioSelect.value ? {
exact: audioSelect.value
} : undefined
}
};
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
preview.srcObject = stream;
return stream;
} catch (err) {
showError('Failed to get stream: ' + err.message);
throw err;
}
}
function showError(message) {
errorDisplay.textContent = message;
}
if (!navigator.mediaDevices?.getUserMedia) {
showError('getUserMedia not supported');
} else {
navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
.then(initialStream => {
initialStream.getTracks().forEach(track => track.stop());
getDevices();
})
.catch(err => showError('Initial permission request failed: ' + err.message));
navigator.mediaDevices.addEventListener('devicechange', getDevices);
}
const messageInput = document.querySelector('.message-input');
const sendButton = document.querySelector('#sendButton');
responsesDiv.parentElement.insertBefore(messageInput, responsesDiv);
responsesDiv.parentElement.insertBefore(sendButton, responsesDiv);
sendButton.addEventListener('click', async () => {
if (!publisher) {
showError('Please start the stream first');
return;
}
if (messageInput.value.trim()) {
try {
messageFormatter.appendMessage(messageInput.value, true);
await publisher.sendPrompt(messageInput.value);
messageInput.value = '';
} catch (err) {
console.error('Failed to send message:', err);
showError('Failed to send message: ' + err.message);
}
}
});
document.getElementById('voiceSelect').addEventListener('change', async () => {
if (publisher && startButton.textContent === 'Stop Stream') {
startButton.textContent = 'Starting...';
startButton.disabled = true;
publisher.stop();
publisher = null;
try {
const stream = await getStream();
preview.srcObject = stream;
const apiKey = document.getElementById('apiKey').value;
publisher = new GoogleLivePublisher(stream, apiKey);
await publisher.start();
startButton.textContent = 'Stop Stream';
startButton.disabled = false;
} catch (err) {
console.error(err);
showError('Failed to restart with new voice: ' + err.message);
startButton.textContent = 'Start Stream';
startButton.disabled = false;
}
}
});
document.getElementById('responseType').addEventListener('change', function() {
const voiceSelect = document.getElementById('voiceSelect');
voiceSelect.style.display = this.value === 'audio' ? 'block' : 'none';
if (publisher && startButton.textContent === 'Stop Stream') {
startButton.textContent = 'Starting...';
startButton.disabled = true;
publisher.stop();
publisher = null;
(async () => {
try {
const stream = await getStream();
preview.srcObject = stream;
const apiKey = document.getElementById('apiKey').value;
publisher = new GoogleLivePublisher(stream, apiKey);
await publisher.start();
startButton.textContent = 'Stop Stream';
startButton.disabled = false;
} catch (err) {
console.error(err);
showError('Failed to restart with new response type: ' + err.message);
startButton.textContent = 'Start Stream';
startButton.disabled = false;
}
})();
}
});
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendButton.click();
}
});
</script>
</body>
</html>

290
examples/httpwssapi.md Normal file
View File

@@ -0,0 +1,290 @@
# VDO.Ninja Remote Control API Documentation
## Overview
VDO.Ninja's Remote Control API allows programmatic control of VDO.Ninja sessions via HTTP or WebSocket connections. This powerful API enables integration with stream decks, custom applications, and automation tools for controlling cameras, microphones, layouts, and other features.
## Basic Setup
To enable the API on any VDO.Ninja instance, add the `&api` parameter with a unique API key:
```
https://vdo.ninja/?api=YOUR_UNIQUE_API_KEY&webcam
```
This key must be kept private and will be used to authenticate API requests. The same key must be used when making API calls to control this specific VDO.Ninja instance.
## Connection Methods
The API supports three connection methods:
1. **WebSocket API** (recommended for real-time control)
2. **HTTP GET API** (good for simple controllers and hotkeys)
3. **Server-Sent Events** (SSE) for one-way event monitoring
### WebSocket API
Connect to `wss://api.vdo.ninja:443` and authenticate with your API key:
```javascript
const socket = new WebSocket("wss://api.vdo.ninja:443");
socket.onopen = function() {
// Join with your API key
socket.send(JSON.stringify({"join": "YOUR_UNIQUE_API_KEY"}));
// After joining, you can send commands
socket.send(JSON.stringify({
"action": "mic",
"value": false // mute microphone
}));
};
// Listen for responses and events
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
```
### HTTP GET API
Structure: `https://api.vdo.ninja/{apiKey}/{action}/{target}/{value}`
Examples:
```
https://api.vdo.ninja/YOUR_UNIQUE_API_KEY/mic/false // Mute microphone
https://api.vdo.ninja/YOUR_UNIQUE_API_KEY/camera/toggle // Toggle camera
```
### Server-Sent Events (SSE)
For monitoring events without sending commands:
```javascript
const eventSource = new EventSource(`https://api.vdo.ninja/sse/YOUR_UNIQUE_API_KEY`);
eventSource.onmessage = function(event) {
console.log(JSON.parse(event.data));
};
```
## API Commands Reference
### Self-Targeted Commands
These commands affect the local VDO.Ninja instance that has the API key enabled.
| Action | Value Options | Description |
|--------|--------------|-------------|
| `mic` | `true`, `false`, `toggle` | Control microphone state |
| `camera` | `true`, `false`, `toggle` | Control camera state |
| `speaker` | `true`, `false`, `toggle` | Control speaker state |
| `volume` | `0` to `200` | Set playback volume (percentage) |
| `bitrate` | Integer (kbps), `-1` for auto | Set video bitrate |
| `record` | `true`, `false` | Control local recording |
| `hangup` | N/A | Disconnect current session |
| `reload` | N/A | Reload the page |
| `sendChat` | Text string | Send a chat message |
| `togglehand` | N/A | Toggle raised hand status |
| `togglescreenshare` | N/A | Toggle screen sharing |
| `forceKeyframe` | N/A | Force video keyframes ("rainbow puke fix") |
| `getDetails` | N/A | Get detailed state information |
| `getGuestList` | N/A | Get list of connected guests with IDs |
### Layout Control Commands
| Action | Value | Description |
|--------|-------|-------------|
| `layout` | `0` or `false` | Switch to auto-mixer layout |
| `layout` | Integer (`1`, `2`, etc.) | Switch to specific predefined layout |
| `layout` | Layout object/array | Apply custom layout configuration |
### Camera Control (PTZ) Commands
| Action | Value | Description |
|--------|-------|-------------|
| `zoom` | `-1.0` to `1.0` | Adjust zoom level (relative) |
| `zoom` | `0.0` to `1.0` with `value2="abs"` | Set absolute zoom level |
| `focus` | `-1.0` to `1.0` | Adjust focus (relative) |
| `pan` | `-1.0` to `1.0` | Adjust camera pan (negative=left) |
| `tilt` | `-1.0` to `1.0` | Adjust camera tilt (negative=down) |
| `exposure` | `0.0` to `1.0` | Adjust camera exposure |
### Group Communication Commands
| Action | Value | Description |
|--------|-------|-------------|
| `group` | `1` to `8` | Toggle participation in specified group |
| `joinGroup` | `1` to `8` | Join a specific group |
| `leaveGroup` | `1` to `8` | Leave a specific group |
| `viewGroup` | `1` to `8` | Toggle view of specified group |
| `joinViewGroup` | `1` to `8` | View a specific group |
| `leaveViewGroup` | `1` to `8` | Stop viewing a specific group |
### Timer Commands
| Action | Value | Description |
|--------|-------|-------------|
| `startRoomTimer` | Integer (seconds) | Start countdown timer for room |
| `pauseRoomTimer` | N/A | Pause the room timer |
| `stopRoomTimer` | N/A | Stop and reset the room timer |
### Presentation Control
| Action | Value | Description |
|--------|-------|-------------|
| `nextSlide` | N/A | Advance to next slide (for PowerPoint integration) |
| `prevSlide` | N/A | Go to previous slide |
| `soloVideo` | `true`, `false`, `toggle` | Highlight video for all guests |
### Director-Only Guest Commands
These commands target specific guests when you are the director.
| Action | Target | Value | Description |
|--------|--------|-------|-------------|
| `forward` | Guest ID/slot | Room name | Transfer guest to another room |
| `addScene` | Guest ID/slot | Scene ID (1-8) | Toggle guest in/out of scene |
| `muteScene` | Guest ID/slot | Scene ID | Toggle guest's audio in scene |
| `mic` | Guest ID/slot | `true`, `false`, `toggle` | Control guest's microphone |
| `hangup` | Guest ID/slot | N/A | Disconnect a specific guest |
| `soloChat` | Guest ID/slot | N/A | Private chat with guest |
| `soloChatBidirectional` | Guest ID/slot | N/A | Two-way private chat |
| `speaker` | Guest ID/slot | N/A | Toggle guest's speaker |
| `display` | Guest ID/slot | N/A | Toggle guest's display |
| `forceKeyframe` | Guest ID/slot | N/A | Fix video artifacts for guest |
| `soloVideo` | Guest ID/slot | N/A | Highlight specific guest's video |
| `volume` | Guest ID/slot | `0` to `100` | Set guest's microphone volume |
| `mixorder` | Guest ID/slot | `-1` or `1` | Change guest's position in mixer |
## Target Parameter Explanation
When using director commands, you can specify targets in two ways:
1. **Slot number**: Simple integers like `1`, `2`, `3` (corresponds to position in room)
2. **Stream ID**: The unique ID for a specific guest (more reliable as slots can change)
Examples:
```javascript
// Target guest in slot 1
{"action": "mic", "target": 1, "value": false}
// Target guest with specific stream ID
{"action": "mic", "target": "abc123xyz", "value": false}
```
## Callbacks and Responses
API commands receive callbacks with the current state after execution:
```javascript
// WebSocket example response when toggling mic
{
"callback": {
"action": "mic",
"value": "toggle",
"result": false // Indicates mic is now muted
}
}
```
## Custom Layout Format
The layout API supports complex scene configurations. Layouts can be arrays of objects with properties:
```javascript
// Simple layout with two videos
{
"action": "layout",
"value": [
{"x": 0, "y": 0, "w": 50, "h": 100, "slot": 0},
{"x": 50, "y": 0, "w": 50, "h": 100, "slot": 1}
]
}
```
Layout object properties:
- `x`, `y`: Position (percentage of canvas)
- `w`, `h`: Width and height (percentage)
- `slot`: Which video slot to display (0-indexed)
- `z`: Z-index for layering (optional)
- `c`: Cover mode (true/false, optional)
## Implementation Examples
### Python Example
```python
import websockets
import asyncio
import json
async def control_camera():
async with websockets.connect("wss://api.vdo.ninja:443") as websocket:
# Join with API key
await websocket.send(json.dumps({"join": "YOUR_API_KEY"}))
# Zoom in camera
await websocket.send(json.dumps({
"action": "zoom",
"value": 0.5,
"value2": "abs"
}))
# Wait for response
response = await websocket.recv()
print(f"Response: {response}")
asyncio.run(control_camera())
```
### JavaScript HTTP Example
```javascript
// Toggle microphone via HTTP
fetch("https://api.vdo.ninja/YOUR_API_KEY/mic/toggle")
.then(response => response.text())
.then(result => console.log("Mic toggled, new state:", result));
```
## Integration with Automation Tools
The API integrates well with:
1. **BitFocus Companion**: Official module available at [github.com/bitfocus/companion-module-vdo-ninja](https://github.com/bitfocus/companion-module-vdo-ninja)
2. **Stream Deck**: Can use HTTP requests for button actions
3. **Node-RED**: Great for complex automation workflows
4. **Home Assistant**: For smart home integration
## Security Considerations
- Keep your API key private
- Consider using unique keys for different productions
- The API has full control over the VDO.Ninja instance it's connected to
- All connections are encrypted over SSL/TLS
## Troubleshooting
- Ensure the API key matches exactly between VDO.Ninja and your requests
- For WebSocket connections, implement reconnection logic (connections timeout after ~1 minute of inactivity)
- When using HTTP API, a `timeout` response means the request couldn't reach the target
## Additional Resources
- Complete API documentation: [github.com/steveseguin/Companion-Ninja](https://github.com/steveseguin/Companion-Ninja)
- Interactive demo: [companion.vdo.ninja](https://companion.vdo.ninja)
- For Python implementations: See the Python sample in the repository
## Advanced Usage: Self-Hosting the API
For production environments, you can self-host the API server:
1. Clone the repository from GitHub
2. Install dependencies with `npm install`
3. Modify the server URL in your VDO.Ninja instances:
```javascript
session.apiserver = "wss://your-custom-domain:443";
```
4. Run the server with proper SSL certificates
Note: Self-hosting support is limited and should only be attempted by experienced developers.

258
examples/iframeapi.md Normal file
View File

@@ -0,0 +1,258 @@
# Understanding the VDO.Ninja IFRAME API: Detecting User Joins and Disconnects
The VDO.Ninja IFRAME API allows websites to embed and interact with VDO.Ninja streams. One of the most useful features is the ability to detect when users join or disconnect from your stream through event messaging. This guide will explain how to implement this functionality in your own projects.
## How the IFRAME API Works
VDO.Ninja's IFRAME API uses the browser's `postMessage` API to communicate between your parent website and the embedded VDO.Ninja iframe. This allows you to:
1. Send commands to control the VDO.Ninja instance
2. Receive events and data from the VDO.Ninja instance
## Setting Up the Basic Structure
First, you need to create an iframe that loads VDO.Ninja:
```javascript
// Create the iframe element
var iframe = document.createElement("iframe");
// Set necessary permissions
iframe.allow = "camera;microphone;fullscreen;display-capture;autoplay;";
// Set the source URL (your VDO.Ninja room)
iframe.src = "https://vdo.ninja/?room=your-room-name&cleanoutput";
// Add the iframe to your page
document.getElementById("container").appendChild(iframe);
```
## Setting Up the Event Listener
To detect joins and disconnects, you need to set up an event listener for messages from the iframe:
```javascript
// Set up event listener (cross-browser compatible)
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
// Add the event listener
eventer(messageEvent, function (e) {
// Make sure the message is from our VDO.Ninja iframe
if (e.source != iframe.contentWindow) return;
// Log the data for debugging
console.log(e.data);
// Process specific events
if ("action" in e.data) {
// Handle different actions
handleAction(e.data);
}
}, false);
```
## Detecting User Joins and Disconnects
The key events to watch for are:
### Guest Connections
```javascript
function handleAction(data) {
if (data.action === "guest-connected") {
// A new guest has connected
console.log("Guest connected:", data.streamID);
// You can access additional info if available
if (data.value && data.value.label) {
console.log("Guest label:", data.value.label);
}
}
else if (data.action === "view-connection") {
// Someone viewing the stream has connected
console.log("Viewer connected:", data.streamID);
// The value property will be true for connections
if (data.value) {
console.log("New viewer connected");
} else {
console.log("Viewer disconnected");
}
}
else if (data.action === "director-connected") {
// The director has connected
console.log("Director connected");
}
else if (data.action === "scene-connected") {
// A scene has connected
console.log("Scene connected:", data.value); // Scene ID
}
else if (data.action === "slot-updated") {
// A stream has been assigned to a slot
console.log("Stream", data.streamID, "assigned to slot", data.value);
}
}
```
### Disconnections
```javascript
function handleAction(data) {
// Handling disconnections
if (data.action === "view-connection" && data.value === false) {
// A viewer has disconnected
console.log("Viewer disconnected:", data.streamID);
}
else if (data.action === "director-share" && data.value === false) {
// A director has stopped sharing
console.log("Director stopped sharing:", data.streamID);
}
else if (data.action === "push-connection" && data.value === false) {
// A guest has disconnected
console.log("Guest disconnected:", data.streamID);
}
}
```
## Complete Working Example
Here's a complete example that demonstrates detecting joins and disconnects:
```javascript
// Create the container for the iframe
var container = document.createElement("div");
container.id = "vdo-container";
document.body.appendChild(container);
// Create the iframe element
var iframe = document.createElement("iframe");
iframe.allow = "camera;microphone;fullscreen;display-capture;autoplay;";
iframe.src = "https://vdo.ninja/?room=your-room-name&cleanoutput";
iframe.style.width = "100%";
iframe.style.height = "100%";
container.appendChild(iframe);
// Create a status display element
var statusDiv = document.createElement("div");
statusDiv.id = "connection-status";
document.body.appendChild(statusDiv);
// Set up event listener
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
// Keep track of connected users
var connectedUsers = {};
// Add the event listener
eventer(messageEvent, function (e) {
// Make sure the message is from our VDO.Ninja iframe
if (e.source != iframe.contentWindow) return;
// Log all messages for debugging
console.log(e.data);
// Process specific actions
if ("action" in e.data) {
handleAction(e.data);
}
}, false);
function handleAction(data) {
// Handle connections
if (data.action === "guest-connected" && data.streamID) {
connectedUsers[data.streamID] = data.value?.label || "Guest";
updateStatusDisplay("Guest connected: " + (data.value?.label || data.streamID));
}
else if (data.action === "view-connection") {
if (data.value && data.streamID) {
connectedUsers[data.streamID] = "Viewer";
updateStatusDisplay("Viewer connected: " + data.streamID);
} else if (data.streamID) {
delete connectedUsers[data.streamID];
updateStatusDisplay("Viewer disconnected: " + data.streamID);
}
}
else if (data.action === "director-connected") {
updateStatusDisplay("Director connected");
}
else if (data.action === "push-connection" && data.value === false && data.streamID) {
delete connectedUsers[data.streamID];
updateStatusDisplay("User disconnected: " + data.streamID);
}
}
function updateStatusDisplay(message) {
var timestamp = new Date().toLocaleTimeString();
statusDiv.innerHTML += `<p>${timestamp}: ${message}</p>`;
// Update connected users count
var count = Object.keys(connectedUsers).length;
document.getElementById("user-count").textContent = count;
}
// Add a user count display
var countDiv = document.createElement("div");
countDiv.innerHTML = "Connected users: <span id='user-count'>0</span>";
document.body.insertBefore(countDiv, statusDiv);
```
## Waiting Room Example
You can implement a waiting room like the one in the `waitingroom.html` file from your code samples:
```javascript
// Setup event listener
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
var waiting = null;
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow) return;
if ("action" in e.data) {
if (e.data.action == "joining-room") {
// Show initial joining message
outputWindow.innerHTML = "JOINING ROOM";
// After 1 second, show waiting message if director hasn't joined
waiting = setTimeout(function() {
outputWindow.innerHTML = "Waiting for the director to join";
outputWindow.classList.remove("hidden");
}, 1000);
}
else if (e.data.action == "director-connected") {
// Director has joined, clear waiting message
clearTimeout(waiting);
outputWindow.innerHTML = "";
outputWindow.classList.add("hidden");
}
}
});
```
## Getting Additional Information About Connections
For more detailed information about connections, you can use the `getStreamIDs` or `getDetailedState` commands:
```javascript
// Request info about all connected streams
iframe.contentWindow.postMessage({ "getStreamIDs": true }, "*");
// Request detailed state information
iframe.contentWindow.postMessage({ "getDetailedState": true }, "*");
```
## Best Practices
1. **Always check the source**: Make sure messages are coming from your VDO.Ninja iframe.
2. **Handle disconnections gracefully**: Sometimes connections drop unexpectedly.
3. **Consider implementing reconnection logic**: If important users disconnect, you might want to notify them or attempt to reconnect.
4. **Debug with console.log**: Log all events during development to understand the full message flow.
5. **Test with multiple users**: The behavior can be different depending on who connects first.
By implementing these techniques, you can build sophisticated applications that respond to users joining and leaving your VDO.Ninja sessions, creating more interactive and responsive experiences.

165
examples/labelonly.html Normal file
View File

@@ -0,0 +1,165 @@
<!--
VDO.Ninja Connected Label Overlay
Description: Displays the label of the first connected peer in an overlay for OBS Studio
Parameters:
&room=xx - Comma-separated room IDs to connect to
&password=pp - Optional password for the rooms
&view=xxxx - Optional view parameter
Usage:
- Add as a browser source in OBS Studio
- Can be hosted locally: file:///C:/Users/steve/Code/vdoninja/examples/labelonly.html?view=steve123
- Or online: https://vdo.ninja/examples/labelonly.html?view=steve123
Styling:
- Modify the #labelDisplay CSS styles to customize appearance
- Background, text color, font size and animations can be adjusted
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta charset="UTF-8">
<title>VDO.Ninja - Connected Label</title>
<style>
body, html {
margin: 0;
padding: 0;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-family: Arial, sans-serif;
background-color: transparent;
width: 100%;
}
.electronDraggable {
-webkit-app-region: drag;
}
body > div {
-webkit-app-region: no-drag;
}
.hidden {
display: none;
opacity: 0;
}
#labelDisplay {
font-size: 48px;
font-weight: bold;
color: white;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
text-align: center;
transition: all 0.5s ease;
opacity: 0;
transform: translateY(20px);
}
#labelDisplay.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body class="electronDraggable">
<div id="labelDisplay" class="hidden"></div>
<script>
window.onerror = function(errorMsg, url, lineNumber) {
console.error(errorMsg, lineNumber);
return false;
};
function getById(id) {
return document.getElementById(id);
}
const urlParams = new URLSearchParams(window.location.search);
const roomID = urlParams.get("room") ? ("&scene&room="+urlParams.get("room")) : "";
const password = urlParams.get("password") ? ("&password="+urlParams.get("password")) : "";
const view = urlParams.get("view") ? ("&view="+urlParams.get("view")) : "";
let iframes = [];
let connectedPeers = {};
let currentDisplayedLabel = null;
function updateLabelDisplay() {
const labelDisplay = getById("labelDisplay");
const peerKeys = Object.keys(connectedPeers);
if (peerKeys.length > 0) {
const firstPeer = peerKeys[0];
const label = connectedPeers[firstPeer];
if (label !== currentDisplayedLabel) {
currentDisplayedLabel = label;
labelDisplay.textContent = currentDisplayedLabel;
labelDisplay.classList.remove("hidden");
// Force repaint
void labelDisplay.offsetWidth;
labelDisplay.classList.add("visible");
}
} else {
currentDisplayedLabel = null;
labelDisplay.classList.remove("visible");
setTimeout(() => {
labelDisplay.classList.add("hidden");
}, 500);
}
}
function RecvDataWindow(room) {
const iframe = document.createElement("iframe");
iframe.src = `https://vdo.ninja/?ln${password}${room}${view}&notmobile&label=overlaypage&vd=0&ad=0&novideo&noaudio&cleanoutput`;
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.position = "fixed";
iframe.style.left = "-100px";
iframe.style.top = "-100px";
iframe.id = `frame_${room}`;
iframe.allow = "midi;geolocation;microphone;";
iframes.push(iframe);
document.body.appendChild(iframe);
window.addEventListener("message", function(e) {
if (e.source !== iframe.contentWindow) return;
if ("action" in e.data && e.data.UUID) {
if (e.data.action === "push-connection" && "value" in e.data && !e.data.value) {
delete connectedPeers[e.data.UUID];
updateLabelDisplay();
} else if ((e.data.action === "push-connection-info" || e.data.action === "view-connection-info")
&& e.data.value && e.data.value.label) {
connectedPeers[e.data.UUID] = e.data.value.label;
updateLabelDisplay();
} else if (e.data.action === "view-connection" && "value" in e.data && !e.data.value) {
delete connectedPeers[e.data.UUID];
updateLabelDisplay();
} else if ("label" in e.data) {
connectedPeers[e.data.UUID] = e.data.label;
updateLabelDisplay();
}
}
});
}
if (roomID) {
roomID.split(",").forEach(room => {
RecvDataWindow(room.trim());
});
} else {
RecvDataWindow("");
}
</script>
</body>
</html>

1072
examples/testsdp.html Normal file

File diff suppressed because it is too large Load Diff

1739
examples/wireless.html Normal file

File diff suppressed because it is too large Load Diff