From 7bea8c08ca556416aac243d8a3ae475e94eae2b8 Mon Sep 17 00:00:00 2001 From: Valerie Date: Sun, 14 Aug 2022 07:37:35 -0400 Subject: [PATCH] video first pass --- packages/renderer/src/App.tsx | 13 + packages/renderer/src/Router.tsx | 3 +- packages/renderer/src/components/Audio.tsx | 20 ++ packages/renderer/src/components/Sidebar.tsx | 8 +- packages/renderer/src/components/Voice.tsx | 244 ++++++++++++++++-- .../src/contexts/EphemeralState/PeerState.tsx | 190 ++++++++++---- .../EphemeralState/UserMediaState.tsx | 41 ++- packages/renderer/src/pages/Channel.tsx | 2 - 8 files changed, 441 insertions(+), 80 deletions(-) create mode 100644 packages/renderer/src/components/Audio.tsx diff --git a/packages/renderer/src/App.tsx b/packages/renderer/src/App.tsx index 63e8a4d..5281442 100644 --- a/packages/renderer/src/App.tsx +++ b/packages/renderer/src/App.tsx @@ -47,10 +47,23 @@ export default function App() { --neutral-7: #9ea0a6; --neutral-8: #cbcccc; --neutral-9: #f8f8f2; + + --green: #4db560; } a { color: var(--cyan); } + fieldset { + margin: 8px; + border-radius: 16px; + border-style: solid; + } + legend { + border-width: 2px; + border-style: solid; + border-radius: 16px; + padding: 0px 8px; + } `}
diff --git a/packages/renderer/src/components/Audio.tsx b/packages/renderer/src/components/Audio.tsx new file mode 100644 index 0000000..3753bcf --- /dev/null +++ b/packages/renderer/src/components/Audio.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +export function Audio(props: React.AudioHTMLAttributes & { + srcObject?: MediaStream; +}) { + + const [ref, setRef] = useState(null); + + useEffect(() => { + if (ref === null) + return; + if (props.srcObject === undefined || props.srcObject === null) + return; + ref.srcObject = props.srcObject; + }, [props.srcObject, ref]); + + const filteredProps = Object.fromEntries(Object.entries(props).filter(([key, value]) => key !== 'srcObject')); + + return ; +} diff --git a/packages/renderer/src/components/Sidebar.tsx b/packages/renderer/src/components/Sidebar.tsx index edf497a..b4451fa 100644 --- a/packages/renderer/src/components/Sidebar.tsx +++ b/packages/renderer/src/components/Sidebar.tsx @@ -5,6 +5,8 @@ import { IoMdSettings } from 'react-icons/io'; import useHover from "../hooks/useHover"; import { useContext } from "react"; import { SettingsContext } from "../contexts/EphemeralState/EphemeralState"; +import { ClientsListContext } from "../contexts/EphemeralState/ClientsListState"; +import useClientId from "../hooks/useClientId"; export default function Sidebar() { @@ -43,6 +45,10 @@ function TopSidebar() { } function MiniProfile() { + + const { clientName } = useContext(ClientsListContext); + const { clientId } = useClientId(); + return (
Valerie
+ }}>{clientId && clientName[clientId]}
([]) + + const { send } = useApi({ + 'voice:list'(data: { participants: Connection[] }) { + setConnectedVoiceClientIds(data.participants.map(c => c.clientId)); + }, + 'voice:join'(data: Connection) { + setConnectedVoiceClientIds(ids => ([...ids, data.clientId])); + }, + 'voice:leave'(data: Connection) { + setConnectedVoiceClientIds(ids => ids.filter(id => data.clientId !== id)); + } + }); + + useEffect(() => { + console.log(connectedVoiceClientIds); + }, [connectedVoiceClientIds]) + + useEffect(() => { + send('voice:list', { channelId: channel }) + }, [channel]) const joinCall = useCallback(() => { if(peerId === null || connected === false || channel === null) return; + enable(); join(channel); - send('voice:join', { peerId, channelId: channel }) + send('voice:join', { peerId, channelId: channel }); }, [connected, peerId, channel]); const leaveCall = useCallback(() => { if(peerId === null || connected === false) return; - send('voice:leave', { peerId, channelId: channel }) + leave(); + send('voice:leave', { peerId, channelId: channel }); }, [connected, peerId, channel]); + const inThisCall = inCall && channel === connectedChannel; + return
-
- Peer Info - connected: {connected ? 'true' : 'false'}

- PeerId: {peerId}

-
-
- Actions - - -
+
+
+ {(!inThisCall) ? ( + + ) : ( + <> + muted ? unmute() : mute()} + inverted={muted} + > + + + + + + )} +
+
+
+ + {connectedVoiceClientIds.length === 0 ? ( + No one is here right now + ) : ( +
+ {connectedVoiceClientIds.map(id => { + const connection = connections.find(c => c.clientId === id); + const isMe = clientId === id; + const stream = (isMe ? mediaStream : connection?.mediaStream) ?? undefined + return ( + + ) + })} +
+ )} +
-} \ No newline at end of file +} + +function Participant(props: { + name: string, + stream?: MediaStream +}) { + return ( +
+ + {/*
+ {props.name} +
*/} +
+ ) +} + +function Video(props: React.VideoHTMLAttributes & { + srcObject?: MediaStream +}) { + + const [ref, setRef] = useState(null); + + useEffect(() => { + if(ref === null) return; + if(props.srcObject === undefined || props.srcObject === null) return; + console.log(ref); + ref.srcObject = props.srcObject; + }, [props.srcObject, ref]); + + const filteredProps = Object.fromEntries(Object.entries(props).filter(([key, value]) => key !== 'srcObject')); + + return +} + + +function CircleButton(props: { + onClick: MouseEventHandler, + icon: IconType, + color?: string, + inverted?: boolean, +}) { + + return ( +
+
+ {React.createElement( + props.icon, + { + size: 24, + color: props.inverted ? 'var(--neutral-1)' : 'inherit' + } + )} +
+
+ ) +} +// MdPhoneInTalk +// MdPhoneDisabled \ No newline at end of file diff --git a/packages/renderer/src/contexts/EphemeralState/PeerState.tsx b/packages/renderer/src/contexts/EphemeralState/PeerState.tsx index bfeef5d..851dc6a 100644 --- a/packages/renderer/src/contexts/EphemeralState/PeerState.tsx +++ b/packages/renderer/src/contexts/EphemeralState/PeerState.tsx @@ -2,6 +2,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { Peer, MediaConnection } from "peerjs"; import { UserMediaContext } from "./UserMediaState"; import { useApi } from "/@/lib/useApi"; +import { Audio } from "/@/components/Audio"; +import { sfx } from "/@/lib/sound"; export const PeerContext = createContext<{ connected: boolean; @@ -9,12 +11,16 @@ export const PeerContext = createContext<{ peerId: string | null; join: (channelId: string) => void; leave: () => void; + connections: Connection[]; + connectedChannel: string | null; }>({ connected: false, peerId: null, inCall: false, join: () => {}, - leave: () => {} + leave: () => {}, + connections: [], + connectedChannel: null }); function useCurrent(thing: T) { @@ -27,11 +33,18 @@ function useCurrent(thing: T) { return thingRef.current; } -interface Connection { +export interface Connection { peerId: string; clientId: string; channelId: string; - call: any; + call: MediaConnection | null; + isCaller: boolean; + mediaStream: MediaStream | null; + connected: boolean; +} + +function isCaller(a: string, b:string) { + return [a, b].sort()[0] === a; } export default function PeerState(props: any) { @@ -42,33 +55,105 @@ export default function PeerState(props: any) { const [connected, setConnected] = useState(false); const [peer, setPeer] = useState(null); const [peerId, setPeerId] = useState(null); + const [incomingCalls, setIncomingCalls] = useState([]); + const [outgoingCalls, setOutgoingCalls] = useState([]); + const [connections, setConnections] = useState([]); const [channel, setChannel] = useState(null); const addIncomingCall = useCurrent(useCallback((call: MediaConnection) => { + // TODO filter out incoming calls from the same peerId. setIncomingCalls(incomingCalls => ([...incomingCalls, call])); }, [])); - + + const removeIncomingCalls = (peerIds: string[]) => { + setIncomingCalls(calls => calls.filter(call => !peerIds.includes(call.peer))); + } + + const updateConnection = (peerId: string, data: Partial) => { + setConnections(connections => connections.map(connection => { + if(connection.peerId !== peerId) return connection; + return { + ...connection, + ...data + } + })) + } + + const removeConnection = (peerId: string) => { + setConnections(connections => connections.filter(connection => connection.peerId !== peerId)); + } + + const destroyConnection = (peerId: string) => { + setConnections(connections => connections.filter(connection => { + if(connection.peerId !== peerId) return true; + if(connection.call) { + connection.call.close(); + removeConnection(peerId); + } + })) + } + + const addStream = (id: string, stream: MediaStream) => { + updateConnection(id, { mediaStream: stream, connected: true }); + } + + // accept / reject incoming calls useEffect(() => { - console.log(connections); - }, [connections]); + if(incomingCalls.length === 0) return; + if(mediaStream === null) return; + const possiblePeerIds = connections.map(c => c.peerId); + for(const call of incomingCalls) { + if(!possiblePeerIds.includes(call.peer)) + continue; + call.on('stream', (stream) => addStream(call.peer, stream)); + call.answer(mediaStream); + call.on('close', () => removeConnection(call.peer)); + updateConnection(call.peer, { call }); + } + removeIncomingCalls(possiblePeerIds); + }, [incomingCalls, connections, mediaStream]) + + // call peers that we should call + useEffect(() => { + if(outgoingCalls.length === 0) return; + if(mediaStream === null) return; + if(peer === null) return; + for(const id of outgoingCalls) { + const call = peer.call(id, mediaStream); + call.on('close', () => removeConnection(id)); + call.on('stream', (stream) => addStream(call.peer, stream)); + updateConnection(id, { call }); + } + setOutgoingCalls(prev => prev.filter(id => !outgoingCalls.includes(id))); + }, [outgoingCalls, mediaStream, peer]); const { send } = useApi({ 'voice:join'(data: any) { if(data.channelId !== channel) return; if(data.peerId === peerId) return; + if(peerId === null) return; + sfx.joinCall(); + const newConn: Connection = { + call: null, + connected: false, + clientId: data.clientId, + peerId: data.peerId, + channelId: data.channelId, + isCaller: isCaller(peerId, data.peerId), + mediaStream: null + }; + if(newConn.isCaller) { + setOutgoingCalls(c => [...c, data.peerId]); + } setConnections((connections) => ([ ...connections, - { - call: null, - clientId: data.clientId, - peerId: data.peerId, - channelId: data.channelId - } + newConn ])) }, 'voice:leave'(data: any) { + sfx.leaveCall(); if(data.channelId !== channel) return; if(data.peerId === peerId) return; setConnections(connections => connections.filter(connection => ( @@ -78,69 +163,82 @@ export default function PeerState(props: any) { ))); }, 'voice:list'(data: any) { - if(data.uid !== channel) return + if(data.uid !== channel) return; + if(peerId === null) return; setConnections(connections => { return data.participants .filter((p: any) => p.peerId !== peerId) .map((participant: any) => { const previousCall = null; + const caller = isCaller(peerId, participant.peerId); + if(caller) { + setOutgoingCalls(c => [...c, participant.peerId]); + } return { ...participant, - call: null + call: null, + isCaller: caller } }) - }) - console.log('PEER STATE CONNECTIONS', data); + }); } }, [channel, peerId]); + useEffect(() => { if(connected) return; + if(peer !== null) return; + { + const peer = new Peer(); + setPeer(peer); - const peer = new Peer(); - setPeer(peer); + peer.on('open', (id: string) => { + setConnected(true); + setPeerId(id); + }); - peer.on('open', (id: string) => { - setConnected(true); - setPeerId(id); - }); + peer.on('close', () => { + setConnected(false); + setPeerId(null); + setPeer(null); + }); - peer.on('close', () => { - setConnected(false); - setPeerId(null); - setPeer(null); - }); - - peer.on('call', (call: MediaConnection) => { - addIncomingCall(call); - }); - }, [connected]); - - const dial = useCallback((id: string) => { - if(peer === null) return; - if(mediaStream === null) return; - peer.call(id, mediaStream); - }, [peer, mediaStream]) + peer.on('call', (call: MediaConnection) => { + addIncomingCall(call); + }); + } + }, [connected, peer]); const joinChannel = (channelId: string) => { + sfx.joinCall(); setChannel(channelId); - send('voice:list', { channelId }) + send('voice:list', { channelId }); } - useEffect(() => { - if(channel === null) return; - console.log('WE JOINED A CHANNEL') - }, [channel]) + const leaveChannel = () => { + setChannel(null); + } const value = useMemo(() => ({ connected, peerId, - inCall: connections.length === 0, + inCall: channel !== null, join: joinChannel, - leave: () => {} - }), [connected, peerId, connections]); + leave: leaveChannel, + connections, + connectedChannel: channel + }), [connected, peerId, connections, channel]); return +
+ {connections.map(conn => ( + (conn.mediaStream !== null) && ( +
+ +
+ ) + ))} +
{props.children}
} diff --git a/packages/renderer/src/contexts/EphemeralState/UserMediaState.tsx b/packages/renderer/src/contexts/EphemeralState/UserMediaState.tsx index 1e2e211..a10bd0b 100644 --- a/packages/renderer/src/contexts/EphemeralState/UserMediaState.tsx +++ b/packages/renderer/src/contexts/EphemeralState/UserMediaState.tsx @@ -5,22 +5,36 @@ export const UserMediaContext = createContext<{ mediaStream: MediaStream | null; enable: () => void; disable: () => void; + mute: () => void; + unmute: () => void; + muted: boolean; + enableCamera: () => void; + disableCamera: () => void; + cameraEnabled: boolean; }>({ enabled: false, mediaStream: null, enable: () => {}, disable: () => {}, + mute: () => {}, + unmute: () => {}, + muted: false, + enableCamera: () => {}, + disableCamera: () => {}, + cameraEnabled: false, }); export default function UserMediaState(props: any) { const [mediaStream, setMediaStream] = useState(null); const [enabled, setEnabled] = useState(false); + const [muted, setMuted] = useState(false); + const [cameraEnabled, setCameraEnabled] = useState(false); const enable = useCallback(async () => { const newStream = await navigator.mediaDevices.getUserMedia({ audio: true, - video: false, + video: true, }); setMediaStream(newStream); @@ -36,14 +50,29 @@ export default function UserMediaState(props: any) { setMediaStream(null); setEnabled(false); - }, [mediaStream]) + }, [mediaStream]); + + const mute = () => { + if(mediaStream === null) return; + mediaStream.getAudioTracks()[0].enabled = false; + setMuted(true); + } + + const unmute = () => { + if(mediaStream === null) return; + mediaStream.getAudioTracks()[0].enabled = true; + setMuted(false); + } const value = useMemo(() => ({ - enabled: false, - mediaStream: null, + enabled, + mediaStream, enable, - disable - }), []); + disable, + mute, + unmute, + muted + }), [enabled, mediaStream, enable, disable, muted]); diff --git a/packages/renderer/src/pages/Channel.tsx b/packages/renderer/src/pages/Channel.tsx index fd578d8..17de65b 100644 --- a/packages/renderer/src/pages/Channel.tsx +++ b/packages/renderer/src/pages/Channel.tsx @@ -41,7 +41,6 @@ export default function Channel(props: ChannelProps) { peerId: data.peerId, channelId: data.channelId }]) - sfx.joinCall(); }, 'voice:list'(data: any) { if(type !== 'voice') return; @@ -49,7 +48,6 @@ export default function Channel(props: ChannelProps) { setParticipants(data.participants); }, 'voice:leave'(data: any) { - sfx.leaveCall(); setParticipants(participants => participants.filter(p => ( p.channelId !== data.channelId || p.clientId !== data.clientId ||