1 Commits
trunk ... rtc

Author SHA1 Message Date
Gavin McDonald
aa938f7258 this is a pain in the ass 2025-06-23 15:33:04 -04:00
6 changed files with 377 additions and 20 deletions

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import useSocket from '@/hooks/useSocket';
import useRTC from '@/hooks/useRTC';
import { Eye } from 'lucide-react';
import Card from '@/components/Card';
@@ -40,6 +41,8 @@ export default function GamePage() {
const selectDeck: Deck | null = selectCard >= 0 ? cards[selectCard].deck : null;
const socket = useSocket({ gameID, setGameData, setNoGame });
const rtc = useRTC(socket);
console.log('useRTC:', rtc);
useEffect(() => {
if (gameIDParam) {

95
hooks/useChatGPT.ts Normal file
View File

@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
interface CursorPosition {
x: number;
y: number;
}
interface PeerMouseHook {
cursors: Record<string, CursorPosition>;
}
export function usePeerMouse(roomId: string): PeerMouseHook {
const [cursors, setCursors] = useState<Record<string, CursorPosition>>({});
const socketRef = useRef<Socket | null>(null);
const peers = useRef<Record<string, RTCPeerConnection>>({});
const channels = useRef<Record<string, RTCDataChannel>>({});
useEffect(() => {
const socket = io();
socketRef.current = socket;
socket.emit('join-room', roomId);
socket.on('new-peer', async (peerId: string) => {
const pc = createPeer(peerId, true);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('signal', { to: peerId, data: { description: pc.localDescription } });
});
socket.on('signal', async ({ from, data }) => {
const pc = peers.current[from] || createPeer(from, false);
if (data.description) {
await pc.setRemoteDescription(data.description);
if (data.description.type === 'offer') {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('signal', { to: from, data: { description: pc.localDescription } });
}
}
if (data.candidate) {
await pc.addIceCandidate(data.candidate);
}
});
function createPeer(peerId: string, isInitiator: boolean): RTCPeerConnection {
const pc = new RTCPeerConnection();
if (isInitiator) {
const channel = pc.createDataChannel('mouse');
setupChannel(peerId, channel);
} else {
pc.ondatachannel = (e) => setupChannel(peerId, e.channel);
}
pc.onicecandidate = (e) => {
if (e.candidate) {
socket.emit('signal', { to: peerId, data: { candidate: e.candidate } });
}
};
peers.current[peerId] = pc;
return pc;
}
function setupChannel(peerId: string, channel: RTCDataChannel) {
channels.current[peerId] = channel;
channel.onmessage = (e) => {
const pos = JSON.parse(e.data);
setCursors((prev) => ({ ...prev, [peerId]: pos }));
};
}
function handleMouseMove(e: MouseEvent) {
const pos = JSON.stringify({ x: e.clientX, y: e.clientY });
Object.values(channels.current).forEach((ch) => {
if (ch.readyState === 'open') ch.send(pos);
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
socket.disconnect();
Object.values(peers.current).forEach((pc) => pc.close());
};
}, [roomId]);
return { cursors };
}

54
hooks/useRTC.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import RTCPeer from '@/lib/RTCPeer';
import type { UseSocket } from '@/hooks/useSocket';
import type {} from '@/types';
// interface UseSocketProps {
// gameID: string;
// setGameData: (gameUpdate: GameUpdate) => void;
// setNoGame: (noGame: boolean) => void;
// }
const channelName = 'tilt';
export default function useRTC({
ready,
registerAnsweredReceiver,
registerOfferredReceiver,
rtcAnswer: sendAnswer,
rtcOffer: sendOffer,
}: UseSocket) {
const [peers, setPeers] = useState<RTCPeer[]>([]);
const answerHandler = (answer: RTCSessionDescriptionInit) => {
console.log('[useRTC] answer received', answer);
console.log('[useRTC] peers:', peers.length);
const peer = peers[0];
console.log('peer:', peer);
peer.onAnswer(answer);
};
const offerHandler = (offer: RTCSessionDescriptionInit) => {
console.log('[useRTC] offer received', offer);
setPeers((peers) => {
peers.push(new RTCPeer({ channelName, offer, sendAnswer, sendOffer }));
return peers;
});
};
useEffect(() => {
if (ready) {
console.log('-=-= SETTING THINGS UP =-=-');
registerAnsweredReceiver(answerHandler);
registerOfferredReceiver(offerHandler);
setPeers([new RTCPeer({ channelName, sendAnswer, sendOffer })]);
}
}, [ready]);
return {
count: peers.length,
};
}

View File

@@ -1,20 +1,37 @@
import { useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { socket } from '@/socket';
import type { GameUpdate } from '@/types';
interface UseSocketProps {
export interface UseSocketProps {
gameID: string;
setGameData: (gameUpdate: GameUpdate) => void;
setNoGame: (noGame: boolean) => void;
}
export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketProps) {
export interface UseSocket {
ready: boolean;
flipCard: (cardIndex: number) => void;
handleSettings: (cardData: GameUpdate) => void;
redraw: (cardIndex: number) => void;
rtcAnswer: (answer: RTCSessionDescriptionInit) => void;
registerAnsweredReceiver: (receiver: (answer: RTCSessionDescriptionInit) => void) => void;
rtcOffer: (offer: RTCSessionDescriptionInit) => void;
registerOfferredReceiver: (receiver: (offer: RTCSessionDescriptionInit) => void) => void;
select: (cardIndex: number, cardID: string) => void;
}
export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketProps): UseSocket {
const [ready, setReady] = useState(false);
const answerRef = useRef<(answer: RTCSessionDescriptionInit) => void>(null);
const offerRef = useRef<(offer: RTCSessionDescriptionInit) => void>(null);
useEffect(() => {
if (gameID) {
socket.emit('join', gameID);
socket.on('init', (data: GameUpdate) => {
setReady(true);
setGameData(data);
});
@@ -30,6 +47,16 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP
socket.on('flip-error', (error) => {
console.error('Error:', error);
});
socket.on('rtc-answered', (answered: RTCSessionDescriptionInit) => {
if (answerRef.current) answerRef.current(answered);
});
socket.on('rtc-offered', (offered: RTCSessionDescriptionInit) => {
if (offerRef.current) {
offerRef.current(offered);
}
});
}
return () => {
@@ -38,27 +65,16 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP
}, [gameID]);
const flipCard = (cardIndex: number) => {
console.log('flip-card', {
gameID,
cardIndex,
});
socket.emit('flip-card', {
gameID,
cardIndex,
});
};
const redraw = (cardIndex: number) => {
socket.emit('redraw', {
gameID,
cardIndex,
});
};
const select = (cardIndex: number, cardID: string) => {
socket.emit('select', {
gameID,
cardIndex,
cardID,
});
};
const handleSettings = (gameData: GameUpdate) => {
socket.emit('settings', {
gameID,
@@ -66,10 +82,42 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP
});
};
const redraw = (cardIndex: number) => {
socket.emit('redraw', {
gameID,
cardIndex,
});
};
const rtcAnswer = (answer: RTCSessionDescriptionInit) => {
console.log('rtc-answer', { gameID, answer });
socket.emit('rtc-answer', { gameID, answer });
};
const rtcOffer = (offer: RTCSessionDescriptionInit) => {
console.log('rtc-offer', { gameID, offer });
socket.emit('rtc-offer', { gameID, offer });
};
const select = (cardIndex: number, cardID: string) => {
socket.emit('select', {
gameID,
cardIndex,
cardID,
});
};
return {
ready,
flipCard,
redraw,
select,
handleSettings,
redraw,
rtcAnswer,
registerAnsweredReceiver: (receiver: (obj: RTCSessionDescriptionInit) => void[]) =>
(answerRef.current = receiver),
rtcOffer,
registerOfferredReceiver: (receiver: (obj: RTCSessionDescriptionInit) => void[]) =>
(offerRef.current = receiver),
select,
};
}

131
lib/RTCPeer.ts Normal file
View File

@@ -0,0 +1,131 @@
const servers = {
iceServers: [
{ url: 'stun:stun01.sipphone.com' },
{ url: 'stun:stun.ekiga.net' },
{ url: 'stun:stun.fwdnet.net' },
{ url: 'stun:stun.ideasip.com' },
{ url: 'stun:stun.iptel.org' },
{ url: 'stun:stun.rixtelecom.se' },
{ url: 'stun:stun.schlund.de' },
{ url: 'stun:stun.l.google.com:19302' },
{ url: 'stun:stun1.l.google.com:19302' },
{ url: 'stun:stun2.l.google.com:19302' },
{ url: 'stun:stun3.l.google.com:19302' },
{ url: 'stun:stun4.l.google.com:19302' },
{ url: 'stun:stunserver.org' },
{ url: 'stun:stun.softjoys.com' },
{ url: 'stun:stun.voiparound.com' },
{ url: 'stun:stun.voipbuster.com' },
{ url: 'stun:stun.voipstunt.com' },
{ url: 'stun:stun.voxgratia.org' },
{ url: 'stun:stun.xten.com' },
// {
// url: 'turn:numb.viagenie.ca',
// credential: 'muazkh',
// username: 'webrtc@live.com',
// },
// {
// url: 'turn:192.158.29.39:3478?transport=udp',
// credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
// username: '28224511:1379330808',
// },
// {
// url: 'turn:192.158.29.39:3478?transport=tcp',
// credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
// username: '28224511:1379330808',
// },
],
};
const pcConstraints = {
optional: [{ DtlsSrtpKeyAgreement: true }],
};
export interface RTCPeerProps {
channelName: string;
offer?: RTCSessionDescriptionInit;
sendAnswer: (offer: RTCSessionDescriptionInit) => void;
sendOffer: (offer: RTCSessionDescriptionInit) => void;
}
export default class RTCPeer {
channelName: string;
peerConnection: RTCPeerConnection;
channel: RTCDataChannel;
sendAnswer: (offer: RTCSessionDescriptionInit) => void;
sendOffer: (offer: RTCSessionDescriptionInit) => void;
constructor({ channelName, offer, sendAnswer, sendOffer }: RTCPeerProps) {
this.sendOffer = sendOffer;
this.sendAnswer = sendAnswer;
this.channelName = channelName;
this.peerConnection = new RTCPeerConnection(); //(servers, pcConstraints);
this.peerConnection.onicecandidate = offer
? this.#handleIceCandidateAnswer
: this.#handleIceCandidateOffer;
this.#createDataChannel();
if (offer) {
console.log('answer');
this.peerConnection.setRemoteDescription(offer);
this.peerConnection.createAnswer().then((answer) => {
this.peerConnection.setLocalDescription(answer);
});
} else {
console.log('call');
this.peerConnection.createOffer().then((offer) => {
this.peerConnection.setLocalDescription(offer);
});
}
}
onAnswer = (answer: RTCSessionDescriptionInit) => {
this.peerConnection.setRemoteDescription(answer);
};
#handleIceCandidateAnswer = (event: RTCPeerConnectionIceEvent) => {
if (!event.candidate) {
const answer = this.peerConnection.localDescription;
console.log('send-answer', { answer });
if (answer) {
this.sendAnswer(answer);
}
}
};
#handleIceCandidateOffer = (event: RTCPeerConnectionIceEvent) => {
if (!event.candidate) {
const offer = this.peerConnection.localDescription;
if (offer) {
console.log('send-offer', { offer });
this.sendOffer(offer);
}
}
};
#createDataChannel = () => {
try {
this.channel = this.peerConnection.createDataChannel(this.channelName);
this.channel.onopen = () => {
console.log('Receive Channel[onopen]:', this.channel.readyState);
};
this.channel.onmessage = (event: MessageEvent) => {
console.log('Receive Channel[onmessage]:', event.data);
};
this.channel.onclose = () => {
console.log('Receive Channel[onclose]:', this.channel.readyState);
};
} catch (error) {
console.error('[RTCPeer|#createDataChannel] ERROR', error);
}
};
}

View File

@@ -119,6 +119,32 @@ app.prepare().then(() => {
}
});
socket.on('rtc-answer', ({ gameID, answer }: { gameID: string; answer: any }) => {
try {
const gameState = gameStore.getGame(gameID);
console.log('[rtc-answer]', gameID);
io.to(gameState.dmID).emit('rtc-answered', answer);
io.to(gameState.spectatorID).emit('rtc-answered', answer);
} catch (e) {
const error = e instanceof Error ? e.message : e;
console.error(Date.now(), 'Error[rtc-answer]', error);
}
});
socket.on('rtc-offer', ({ gameID, offer }: { gameID: string; offer: any }) => {
try {
const gameState = gameStore.getGame(gameID);
console.log('[rtc-offer]', gameID);
io.to(gameState.dmID).emit('rtc-offered', offer);
io.to(gameState.spectatorID).emit('rtc-offered', offer);
} catch (e) {
const error = e instanceof Error ? e.message : e;
console.error(Date.now(), 'Error[rtc-offer]', error);
}
});
socket.on('disconnect', () => {
try {
const game = gameStore.playerExit(socket.id);