387 lines
11 KiB
JavaScript
387 lines
11 KiB
JavaScript
|
|
// ─── Elemente ───
|
|||
|
|
const serverUrlInput = document.getElementById('serverUrl');
|
|||
|
|
const myIdInput = document.getElementById('myId');
|
|||
|
|
const remoteIdInput = document.getElementById('remoteId');
|
|||
|
|
const btnRegister = document.getElementById('btnRegister');
|
|||
|
|
const btnShare = document.getElementById('btnShare');
|
|||
|
|
const btnControl = document.getElementById('btnControl');
|
|||
|
|
const btnStop = document.getElementById('btnStop');
|
|||
|
|
const localVideo = document.getElementById('localVideo');
|
|||
|
|
const remoteVideo = document.getElementById('remoteVideo');
|
|||
|
|
const localPlaceholder = document.getElementById('localPlaceholder');
|
|||
|
|
const remotePlaceholder = document.getElementById('remotePlaceholder');
|
|||
|
|
const statusDot = document.getElementById('statusDot');
|
|||
|
|
const statusText = document.getElementById('statusText');
|
|||
|
|
const peerInfo = document.getElementById('peerInfo');
|
|||
|
|
const remotePanel = document.getElementById('remotePanel');
|
|||
|
|
const logEl = document.getElementById('log');
|
|||
|
|
|
|||
|
|
// ─── State ───
|
|||
|
|
let ws = null;
|
|||
|
|
let peerConnection = null;
|
|||
|
|
let dataChannel = null;
|
|||
|
|
let localStream = null;
|
|||
|
|
let controlActive = false;
|
|||
|
|
let screenWidth = window.screen.width;
|
|||
|
|
let screenHeight = window.screen.height;
|
|||
|
|
|
|||
|
|
myIdInput.value = 'PC-' + Math.random().toString(36).substr(2, 4).toUpperCase();
|
|||
|
|
|
|||
|
|
const ICE_SERVERS = [
|
|||
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|||
|
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|||
|
|
{ urls: 'stun:stun2.l.google.com:19302' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// ─── Logging ───
|
|||
|
|
function log(msg, type = '') {
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.className = type;
|
|||
|
|
div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
|||
|
|
logEl.appendChild(div);
|
|||
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|||
|
|
console.log(msg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── WebSocket Signaling ───
|
|||
|
|
btnRegister.addEventListener('click', () => {
|
|||
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|||
|
|
ws.close();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const url = serverUrlInput.value.trim();
|
|||
|
|
ws = new WebSocket(url);
|
|||
|
|
|
|||
|
|
ws.onopen = () => {
|
|||
|
|
ws.send(JSON.stringify({ type: 'register', peerId: myIdInput.value }));
|
|||
|
|
statusDot.className = 'status-dot online';
|
|||
|
|
statusText.textContent = 'Verbunden als ' + myIdInput.value;
|
|||
|
|
btnRegister.textContent = 'Trennen';
|
|||
|
|
log('Mit Signal-Server verbunden', 'info');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
ws.onclose = () => {
|
|||
|
|
statusDot.className = 'status-dot offline';
|
|||
|
|
statusText.textContent = 'Nicht verbunden';
|
|||
|
|
btnRegister.textContent = 'Verbinden';
|
|||
|
|
log('Signal-Server getrennt');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
ws.onerror = (err) => {
|
|||
|
|
log('WebSocket-Fehler: ' + err.message, 'error');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
ws.onmessage = (event) => {
|
|||
|
|
const msg = JSON.parse(event.data);
|
|||
|
|
handleSignal(msg);
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
async function handleSignal(msg) {
|
|||
|
|
switch (msg.type) {
|
|||
|
|
case 'registered':
|
|||
|
|
log('Registriert als: ' + msg.peerId, 'info');
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'offer':
|
|||
|
|
log('Offer empfangen von ' + msg.from, 'info');
|
|||
|
|
peerInfo.textContent = 'Verbunden mit: ' + msg.from;
|
|||
|
|
await handleOffer(msg);
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'answer':
|
|||
|
|
log('Answer empfangen von ' + msg.from, 'info');
|
|||
|
|
if (peerConnection) {
|
|||
|
|
await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp));
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'ice-candidate':
|
|||
|
|
if (peerConnection && msg.candidate) {
|
|||
|
|
await peerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'control':
|
|||
|
|
if (window.electronAPI) {
|
|||
|
|
window.electronAPI.sendControl(msg.data);
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── WebRTC ───
|
|||
|
|
function createPeerConnection() {
|
|||
|
|
if (peerConnection) {
|
|||
|
|
peerConnection.close();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
peerConnection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
|||
|
|
|
|||
|
|
peerConnection.onicecandidate = (event) => {
|
|||
|
|
if (event.candidate) {
|
|||
|
|
sendSignal({
|
|||
|
|
type: 'ice-candidate',
|
|||
|
|
target: remoteIdInput.value,
|
|||
|
|
candidate: event.candidate,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
peerConnection.ontrack = (event) => {
|
|||
|
|
remoteVideo.srcObject = event.streams[0];
|
|||
|
|
remotePlaceholder.style.display = 'none';
|
|||
|
|
log('Remote-Stream empfangen', 'info');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
peerConnection.ondatachannel = (event) => {
|
|||
|
|
dataChannel = event.channel;
|
|||
|
|
setupDataChannel(dataChannel);
|
|||
|
|
log('DataChannel empfangen (Fernsteuerung bereit)', 'info');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
peerConnection.oniceconnectionstatechange = () => {
|
|||
|
|
log('ICE Status: ' + peerConnection.iceConnectionState);
|
|||
|
|
if (peerConnection.iceConnectionState === 'disconnected' ||
|
|||
|
|
peerConnection.iceConnectionState === 'failed') {
|
|||
|
|
log('Verbindung verloren!', 'error');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return peerConnection;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setupDataChannel(dc) {
|
|||
|
|
dc.onopen = () => log('DataChannel offen – Fernsteuerung aktiv', 'info');
|
|||
|
|
dc.onclose = () => log('DataChannel geschlossen');
|
|||
|
|
dc.onmessage = (event) => {
|
|||
|
|
const data = JSON.parse(event.data);
|
|||
|
|
if (window.electronAPI) {
|
|||
|
|
window.electronAPI.sendControl(data);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sendSignal(msg) {
|
|||
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|||
|
|
ws.send(JSON.stringify(msg));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Bildschirm teilen ───
|
|||
|
|
btnShare.addEventListener('click', async () => {
|
|||
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|||
|
|
log('Zuerst mit Signal-Server verbinden!', 'error');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!remoteIdInput.value.trim()) {
|
|||
|
|
log('Remote-ID eingeben!', 'error');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const sources = await window.electronAPI.getSources();
|
|||
|
|
if (sources.length === 0) {
|
|||
|
|
log('Keine Bildschirmquellen gefunden', 'error');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
localStream = await navigator.mediaDevices.getUserMedia({
|
|||
|
|
audio: false,
|
|||
|
|
video: {
|
|||
|
|
mandatory: {
|
|||
|
|
chromeMediaSource: 'desktop',
|
|||
|
|
chromeMediaSourceId: sources[0].id,
|
|||
|
|
maxWidth: 1920,
|
|||
|
|
maxHeight: 1080,
|
|||
|
|
maxFrameRate: 30,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
localVideo.srcObject = localStream;
|
|||
|
|
localPlaceholder.style.display = 'none';
|
|||
|
|
log('Bildschirm wird erfasst: ' + sources[0].name, 'info');
|
|||
|
|
|
|||
|
|
const pc = createPeerConnection();
|
|||
|
|
|
|||
|
|
dataChannel = pc.createDataChannel('control');
|
|||
|
|
setupDataChannel(dataChannel);
|
|||
|
|
|
|||
|
|
localStream.getTracks().forEach((track) => {
|
|||
|
|
pc.addTrack(track, localStream);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const offer = await pc.createOffer();
|
|||
|
|
await pc.setLocalDescription(offer);
|
|||
|
|
|
|||
|
|
sendSignal({
|
|||
|
|
type: 'offer',
|
|||
|
|
target: remoteIdInput.value,
|
|||
|
|
sdp: offer,
|
|||
|
|
screenWidth,
|
|||
|
|
screenHeight,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
log('Offer gesendet an ' + remoteIdInput.value, 'info');
|
|||
|
|
peerInfo.textContent = 'Verbinde mit: ' + remoteIdInput.value;
|
|||
|
|
} catch (err) {
|
|||
|
|
log('Fehler: ' + err.message, 'error');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
async function handleOffer(msg) {
|
|||
|
|
const pc = createPeerConnection();
|
|||
|
|
|
|||
|
|
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
|
|||
|
|
|
|||
|
|
if (msg.screenWidth) screenWidth = msg.screenWidth;
|
|||
|
|
if (msg.screenHeight) screenHeight = msg.screenHeight;
|
|||
|
|
|
|||
|
|
const answer = await pc.createAnswer();
|
|||
|
|
await pc.setLocalDescription(answer);
|
|||
|
|
|
|||
|
|
sendSignal({
|
|||
|
|
type: 'answer',
|
|||
|
|
target: msg.from,
|
|||
|
|
sdp: answer,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
log('Answer gesendet an ' + msg.from, 'info');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Fernsteuerung (Maus/Tastatur über Remote-Video senden) ───
|
|||
|
|
btnControl.addEventListener('click', () => {
|
|||
|
|
controlActive = !controlActive;
|
|||
|
|
btnControl.textContent = controlActive ? 'Steuerung AN' : 'Fernsteuern';
|
|||
|
|
btnControl.className = controlActive ? 'btn-danger' : 'btn-secondary';
|
|||
|
|
log(controlActive ? 'Fernsteuerung AKTIVIERT' : 'Fernsteuerung DEAKTIVIERT', 'info');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function sendControlData(data) {
|
|||
|
|
if (!controlActive) return;
|
|||
|
|
|
|||
|
|
if (dataChannel && dataChannel.readyState === 'open') {
|
|||
|
|
dataChannel.send(JSON.stringify(data));
|
|||
|
|
} else {
|
|||
|
|
sendSignal({
|
|||
|
|
type: 'control',
|
|||
|
|
target: remoteIdInput.value,
|
|||
|
|
data,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getRelativeCoords(e) {
|
|||
|
|
const rect = remoteVideo.getBoundingClientRect();
|
|||
|
|
const videoWidth = remoteVideo.videoWidth || 1920;
|
|||
|
|
const videoHeight = remoteVideo.videoHeight || 1080;
|
|||
|
|
|
|||
|
|
const scaleX = screenWidth / rect.width;
|
|||
|
|
const scaleY = screenHeight / rect.height;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
x: (e.clientX - rect.left) * scaleX,
|
|||
|
|
y: (e.clientY - rect.top) * scaleY,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
remotePanel.addEventListener('mousemove', (e) => {
|
|||
|
|
const coords = getRelativeCoords(e);
|
|||
|
|
sendControlData({ action: 'mousemove', ...coords });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
remotePanel.addEventListener('mousedown', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const btn = ['left', 'middle', 'right'][e.button] || 'left';
|
|||
|
|
const coords = getRelativeCoords(e);
|
|||
|
|
sendControlData({ action: 'mousedown', button: btn, ...coords });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
remotePanel.addEventListener('mouseup', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const btn = ['left', 'middle', 'right'][e.button] || 'left';
|
|||
|
|
sendControlData({ action: 'mouseup', button: btn });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
remotePanel.addEventListener('contextmenu', (e) => e.preventDefault());
|
|||
|
|
|
|||
|
|
remotePanel.addEventListener('wheel', (e) => {
|
|||
|
|
if (!controlActive) return;
|
|||
|
|
e.preventDefault();
|
|||
|
|
sendControlData({
|
|||
|
|
action: 'scroll',
|
|||
|
|
deltaX: Math.sign(e.deltaX) * 3,
|
|||
|
|
deltaY: Math.sign(e.deltaY) * 3,
|
|||
|
|
});
|
|||
|
|
}, { passive: false });
|
|||
|
|
|
|||
|
|
document.addEventListener('keydown', (e) => {
|
|||
|
|
if (!controlActive) return;
|
|||
|
|
if (e.target.tagName === 'INPUT') return;
|
|||
|
|
e.preventDefault();
|
|||
|
|
|
|||
|
|
const modifiers = [];
|
|||
|
|
if (e.ctrlKey) modifiers.push('control');
|
|||
|
|
if (e.shiftKey) modifiers.push('shift');
|
|||
|
|
if (e.altKey) modifiers.push('alt');
|
|||
|
|
|
|||
|
|
sendControlData({
|
|||
|
|
action: 'keydown',
|
|||
|
|
key: mapKey(e.key),
|
|||
|
|
modifiers,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('keyup', (e) => {
|
|||
|
|
if (!controlActive) return;
|
|||
|
|
if (e.target.tagName === 'INPUT') return;
|
|||
|
|
e.preventDefault();
|
|||
|
|
|
|||
|
|
sendControlData({
|
|||
|
|
action: 'keyup',
|
|||
|
|
key: mapKey(e.key),
|
|||
|
|
modifiers: [],
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function mapKey(key) {
|
|||
|
|
const keyMap = {
|
|||
|
|
'Enter': 'enter', 'Backspace': 'backspace', 'Delete': 'delete',
|
|||
|
|
'Tab': 'tab', 'Escape': 'escape', ' ': 'space',
|
|||
|
|
'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right',
|
|||
|
|
'Home': 'home', 'End': 'end', 'PageUp': 'pageup', 'PageDown': 'pagedown',
|
|||
|
|
'Control': 'control', 'Shift': 'shift', 'Alt': 'alt',
|
|||
|
|
'F1': 'f1', 'F2': 'f2', 'F3': 'f3', 'F4': 'f4', 'F5': 'f5',
|
|||
|
|
'F6': 'f6', 'F7': 'f7', 'F8': 'f8', 'F9': 'f9', 'F10': 'f10',
|
|||
|
|
'F11': 'f11', 'F12': 'f12',
|
|||
|
|
};
|
|||
|
|
return keyMap[key] || key.toLowerCase();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Stopp ───
|
|||
|
|
btnStop.addEventListener('click', () => {
|
|||
|
|
if (localStream) {
|
|||
|
|
localStream.getTracks().forEach((t) => t.stop());
|
|||
|
|
localStream = null;
|
|||
|
|
localVideo.srcObject = null;
|
|||
|
|
localPlaceholder.style.display = '';
|
|||
|
|
}
|
|||
|
|
if (peerConnection) {
|
|||
|
|
peerConnection.close();
|
|||
|
|
peerConnection = null;
|
|||
|
|
}
|
|||
|
|
if (dataChannel) {
|
|||
|
|
dataChannel.close();
|
|||
|
|
dataChannel = null;
|
|||
|
|
}
|
|||
|
|
remoteVideo.srcObject = null;
|
|||
|
|
remotePlaceholder.style.display = '';
|
|||
|
|
controlActive = false;
|
|||
|
|
btnControl.textContent = 'Fernsteuern';
|
|||
|
|
btnControl.className = 'btn-secondary';
|
|||
|
|
peerInfo.textContent = '';
|
|||
|
|
log('Alle Streams gestoppt', 'info');
|
|||
|
|
});
|