video first pass

main
Valerie 2022-08-14 07:37:35 -04:00
parent 91a81d6699
commit 7bea8c08ca
8 changed files with 441 additions and 80 deletions

View File

@ -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;
}
`}</style>
<div style={{
background: transparent ? 'rgba(0, 0, 0, 0)' : 'var(--neutral-3)',

View File

@ -54,7 +54,8 @@ export default function Router(props: RouterProps) {
}}>
<div style={{
height: '50%',
width: '100%'
width: '100%',
overflow: 'auto'
}}>
<Voice></Voice>
</div>

View File

@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
export function Audio(props: React.AudioHTMLAttributes<HTMLAudioElement> & {
srcObject?: MediaStream;
}) {
const [ref, setRef] = useState<HTMLAudioElement | null>(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 <audio ref={setRef} {...filteredProps}>{props.children}</audio>;
}

View File

@ -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 (
<div style={{
fontSize: '16px',
@ -62,7 +68,7 @@ function MiniProfile() {
<div style={{
fontWeight: '400',
fontSize: '15px',
}}>Valerie</div>
}}>{clientId && clientName[clientId]}</div>
<div style={{
fontWeight: '300',
fontSize: '13px',

View File

@ -1,42 +1,238 @@
import { useCallback, useContext } from "react"
import { PeerContext } from "../contexts/EphemeralState/PeerState";
import React from "react";
import { MouseEventHandler, ReactNode, useCallback, useContext, useEffect, useState } from "react"
import { MdHeadphones, MdMic, MdMicOff, MdPhoneDisabled, MdPhoneInTalk, MdScreenShare, MdSend, MdVideocam } from "react-icons/md";
import { ClientsListContext } from "../contexts/EphemeralState/ClientsListState";
import { Connection, PeerContext } from "../contexts/EphemeralState/PeerState";
import { UserMediaContext } from "../contexts/EphemeralState/UserMediaState";
import useChannel from "../hooks/useChannel";
import { useApi } from "../lib/useApi";
import { IconType } from 'react-icons/lib/cjs/iconBase';
import useClientId from "../hooks/useClientId";
export default function Voice(props: any) {
const { uid } = props;
const { connected, peerId, join } = useContext(PeerContext);
export default function Voice() {
const {
connected,
peerId,
join,
leave,
connections,
inCall,
connectedChannel
} = useContext(PeerContext);
const { channel } = useChannel();
const { clientName } = useContext(ClientsListContext);
const { clientId } = useClientId();
const { send } = useApi({});
const {
mute, unmute, muted, enable, mediaStream
} = useContext(UserMediaContext);
const [connectedVoiceClientIds, setConnectedVoiceClientIds] = useState<string[]>([])
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 <div style={{
width: '100%',
maxWidth: '500px',
margin: '0px auto',
background: 'var(--neutral-1)',
height: '100%',
position: 'relative',
}}>
<fieldset>
<legend>Peer Info</legend>
connected: {connected ? 'true' : 'false'}<br></br>
PeerId: {peerId}<br></br>
</fieldset>
<fieldset>
<legend>Actions</legend>
<button onClick={joinCall}>Join Call</button>
<button onClick={leaveCall}>Leave Call</button>
</fieldset>
<div style={{
position: 'absolute',
bottom: '0px',
width: '100%',
display: 'grid',
placeItems: 'center center'
}}>
<div style={{
margin: '0px auto',
display: 'inline',
}}>
{(!inThisCall) ? (
<CircleButton
icon={MdPhoneInTalk}
onClick={joinCall}
color="var(--green)"
></CircleButton>
) : (
<>
<CircleButton
icon={muted ? MdMicOff : MdMic}
onClick={() => muted ? unmute() : mute()}
inverted={muted}
></CircleButton>
<CircleButton
icon={MdHeadphones}
onClick={leaveCall}
></CircleButton>
<CircleButton
icon={MdScreenShare}
onClick={leaveCall}
></CircleButton>
<CircleButton
icon={MdVideocam}
onClick={leaveCall}
></CircleButton>
<CircleButton
icon={MdPhoneDisabled}
onClick={leaveCall}
color="var(--red)"
></CircleButton>
</>
)}
</div>
</div>
<div style={{
display: 'grid',
placeItems: 'center center',
height: '100%',
width: '100%'
}}>
{connectedVoiceClientIds.length === 0 ? (
<span style={{ color: 'var(--neutral-6)', fontWeight: '600' }}>No one is here right now</span>
) : (
<div style={{
}}>
{connectedVoiceClientIds.map(id => {
const connection = connections.find(c => c.clientId === id);
const isMe = clientId === id;
const stream = (isMe ? mediaStream : connection?.mediaStream) ?? undefined
return (
<Participant
name={clientName[id]}
stream={stream}
></Participant>
)
})}
</div>
)}
</div>
</div>
}
}
function Participant(props: {
name: string,
stream?: MediaStream
}) {
return (
<div style={{
width: '200px',
height: '150px',
display: 'inline-block',
placeItems: 'center center',
borderRadius: '8px',
background: 'var(--neutral-4)',
color: 'var(--neutral-8)',
fontStyle: '500',
margin: '4px',
verticalAlign: 'top',
overflow: 'hidden',
}}>
<Video autoPlay muted style={{
width: '100%'
}} srcObject={props.stream}></Video>
{/* <div style={{
display: 'grid',
width: '100%',
height: '100%',
placeItems: 'center center',
}}>
{props.name}
</div> */}
</div>
)
}
function Video(props: React.VideoHTMLAttributes<HTMLVideoElement> & {
srcObject?: MediaStream
}) {
const [ref, setRef] = useState<HTMLVideoElement | null>(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 <video ref={setRef} {...filteredProps}>{props.children}</video>
}
function CircleButton(props: {
onClick: MouseEventHandler<HTMLDivElement>,
icon: IconType,
color?: string,
inverted?: boolean,
}) {
return (
<div style={{
display: 'inline-block',
width: '56px',
height: '64px',
padding: '8px',
paddingRight: '0px',
boxSizing: 'border-box',
}}>
<div onClick={props.onClick} style={{
background: props.inverted ? 'var(--neutral-9)' : (props.color ?? 'var(--neutral-4)'),
width: '100%',
height: '100%',
borderRadius: '50%',
cursor: 'pointer',
display: 'grid',
placeItems: 'center center',
// paddingLeft: '4px',
boxSizing: 'border-box',
}}>
{React.createElement(
props.icon,
{
size: 24,
color: props.inverted ? 'var(--neutral-1)' : 'inherit'
}
)}
</div>
</div>
)
}
// MdPhoneInTalk
// MdPhoneDisabled

View File

@ -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<T>(thing: T) {
@ -27,11 +33,18 @@ function useCurrent<T>(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<Peer | null>(null);
const [peerId, setPeerId] = useState<string | null>(null);
const [incomingCalls, setIncomingCalls] = useState<MediaConnection[]>([]);
const [outgoingCalls, setOutgoingCalls] = useState<string[]>([]);
const [connections, setConnections] = useState<Connection[]>([]);
const [channel, setChannel] = useState<string | null>(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<Connection>) => {
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 <PeerContext.Provider value={value}>
<div>
{connections.map(conn => (
(conn.mediaStream !== null) && (
<div key={conn.peerId}>
<Audio autoPlay hidden srcObject={conn.mediaStream}></Audio>
</div>
)
))}
</div>
{props.children}
</PeerContext.Provider>
}

View File

@ -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<MediaStream | null>(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]);

View File

@ -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 ||