mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
Add files via upload
This commit is contained in:
696
examples/gamecontroller.html
Normal file
696
examples/gamecontroller.html
Normal 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
964
examples/googleai.html
Normal 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
290
examples/httpwssapi.md
Normal 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
258
examples/iframeapi.md
Normal 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
165
examples/labelonly.html
Normal 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}¬mobile&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
1072
examples/testsdp.html
Normal file
File diff suppressed because it is too large
Load Diff
1739
examples/wireless.html
Normal file
1739
examples/wireless.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user