video is working! some features missing

main
Valerie 2022-08-14 18:04:04 -04:00
parent 7bea8c08ca
commit d595cc7373
6 changed files with 307 additions and 136 deletions

View File

@ -0,0 +1,21 @@
import React from "react";
import { useEffect, useState } from "react";
export 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;
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>;
}

View File

@ -1,8 +1,9 @@
import React from "react";
import React, { useLayoutEffect } 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 { MdHeadphones, MdMic, MdMicOff, MdScreenShare, MdSend, MdVideoCall, MdVideocam, MdVideocamOff } from "react-icons/md";
import { FiLogOut, FiLogIn } from 'react-icons/fi';
import { ClientsListContext } from "../contexts/EphemeralState/ClientsListState";
import { Connection, PeerContext } from "../contexts/EphemeralState/PeerState";
import { IParticipant, IConnection, PeerContext } from "../contexts/EphemeralState/PeerState";
import { UserMediaContext } from "../contexts/EphemeralState/UserMediaState";
import useChannel from "../hooks/useChannel";
import { useApi } from "../lib/useApi";
@ -24,33 +25,50 @@ export default function Voice() {
const { clientId } = useClientId();
const {
mute, unmute, muted, enable, mediaStream
mute,
unmute,
muted,
enable,
disable,
mediaStream,
cameraEnabled,
enableCamera,
disableCamera
} = useContext(UserMediaContext);
const [connectedVoiceClientIds, setConnectedVoiceClientIds] = useState<string[]>([])
const [participants, setParticipants] = useState<IParticipant[]>([]);
const { send } = useApi({
'voice:list'(data: { participants: Connection[] }) {
setConnectedVoiceClientIds(data.participants.map(c => c.clientId));
'voice:list'(data: { uid: string, participants: IParticipant[] }) {
if(data.uid !== channel) return;
setParticipants(data.participants);
},
'voice:join'(data: Connection) {
setConnectedVoiceClientIds(ids => ([...ids, data.clientId]));
'voice:join'(data: IParticipant) {
if(data.channelId !== channel) return;
setParticipants(ps => ([...ps, data]));
},
'voice:leave'(data: Connection) {
setConnectedVoiceClientIds(ids => ids.filter(id => data.clientId !== id));
'voice:leave'(data: IParticipant) {
if(data.channelId !== channel) return;
setParticipants(ps => ps.filter(p => p.peerId !== data.peerId));
}
});
useEffect(() => {
console.log(connectedVoiceClientIds);
}, [connectedVoiceClientIds])
}, [channel]);
useEffect(() => {
send('voice:list', { channelId: channel })
}, [channel])
}, [channel]);
const joinCall = useCallback(() => {
if(peerId === null || connected === false || channel === null) return;
disableCamera();
enable();
join(channel);
send('voice:join', { peerId, channelId: channel });
}, [connected, peerId, channel]);
const joinCallWithVideo = useCallback(() => {
if(peerId === null || connected === false || channel === null) return;
enableCamera();
enable();
join(channel);
send('voice:join', { peerId, channelId: channel });
@ -59,6 +77,7 @@ export default function Voice() {
const leaveCall = useCallback(() => {
if(peerId === null || connected === false) return;
leave();
disable();
send('voice:leave', { peerId, channelId: channel });
}, [connected, peerId, channel]);
@ -81,11 +100,18 @@ export default function Voice() {
display: 'inline',
}}>
{(!inThisCall) ? (
<CircleButton
icon={MdPhoneInTalk}
onClick={joinCall}
color="var(--green)"
></CircleButton>
<>
<CircleButton
icon={FiLogIn}
onClick={joinCall}
color="var(--green)"
></CircleButton>
<CircleButton
icon={MdVideoCall}
onClick={joinCallWithVideo}
color="var(--green)"
></CircleButton>
</>
) : (
<>
<CircleButton
@ -102,11 +128,12 @@ export default function Voice() {
onClick={leaveCall}
></CircleButton>
<CircleButton
icon={MdVideocam}
onClick={leaveCall}
icon={cameraEnabled ? MdVideocam : MdVideocamOff}
onClick={() => cameraEnabled ? disableCamera() : enableCamera()}
inverted={!cameraEnabled}
></CircleButton>
<CircleButton
icon={MdPhoneDisabled}
icon={FiLogOut}
onClick={leaveCall}
color="var(--red)"
></CircleButton>
@ -121,20 +148,21 @@ export default function Voice() {
width: '100%'
}}>
{connectedVoiceClientIds.length === 0 ? (
{participants.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
{participants.map(participant => {
const connection = connections.find(c => c.clientId === participant.clientId);
// if(participant.clientId !== clientId) return <div key={participant.peerId}></div>;
return (
<Participant
name={clientName[id]}
stream={stream}
key={participant.peerId}
data={connection ?? participant}
></Participant>
)
})}
@ -145,57 +173,67 @@ export default function Voice() {
}
function Participant(props: {
name: string,
stream?: MediaStream
data: IParticipant | IConnection
}) {
const [videoRoot, setVideoRoot] = useState<HTMLDivElement | null>(null);
const { videoElement } = useContext(UserMediaContext);
const { clientName } = useContext(ClientsListContext);
const isSelf = useClientId().clientId === props.data.clientId;
const remoteVideoElement = isSelf ? (
videoElement
) : (
('videoElement' in props.data) ? (
props.data.videoElement
) : (
null
)
);
useLayoutEffect(() => {
if(videoRoot === null) return;
if(remoteVideoElement === null) return;
const alreadyThere = [...videoRoot.childNodes].includes(remoteVideoElement);
if(!alreadyThere) {
while(!!videoRoot.firstChild) {
videoRoot.firstChild.remove();
}
videoRoot.appendChild(remoteVideoElement);
}
remoteVideoElement.play();
}, [videoRoot, remoteVideoElement]);
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',
margin: '4px',
}}>
<Video autoPlay muted style={{
width: '100%'
}} srcObject={props.stream}></Video>
{/* <div style={{
display: 'grid',
width: '100%',
height: '100%',
<div style={{
width: '200px',
height: '150px',
display: 'inline-block',
placeItems: 'center center',
borderRadius: '8px',
background: isSelf ? 'var(--orange)' : 'var(--neutral-4)',
color: 'var(--neutral-8)',
fontStyle: '500',
overflow: 'hidden',
}}>
{props.name}
</div> */}
<div ref={setVideoRoot} style={{
height: '100%'
}}></div>
</div>
<div style={{
textAlign: 'center'
}}>{clientName[props.data.clientId]}</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,
@ -203,6 +241,8 @@ function CircleButton(props: {
inverted?: boolean,
}) {
const primaryColor = props.inverted ? 'var(--neutral-9)' : (props.color ?? 'var(--neutral-4)');
return (
<div style={{
display: 'inline-block',
@ -213,7 +253,7 @@ function CircleButton(props: {
boxSizing: 'border-box',
}}>
<div onClick={props.onClick} style={{
background: props.inverted ? 'var(--neutral-9)' : (props.color ?? 'var(--neutral-4)'),
background: primaryColor,
width: '100%',
height: '100%',
borderRadius: '50%',

View File

@ -0,0 +1,7 @@
import { useEffect } from "react";
export const useLog = (v: any, prefix = '') => {
useEffect(() => {
console.log(prefix, v);
}, [v]);
};

View File

@ -1,9 +1,12 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Peer, MediaConnection } from "peerjs";
import { UserMediaContext } from "./UserMediaState";
import { useApi } from "/@/lib/useApi";
import { Audio } from "/@/components/Audio";
import { sfx } from "/@/lib/sound";
import { Video } from '../../components/Video';
import React from "react";
import { useLog } from "/@/components/useLog";
export const PeerContext = createContext<{
connected: boolean;
@ -11,7 +14,7 @@ export const PeerContext = createContext<{
peerId: string | null;
join: (channelId: string) => void;
leave: () => void;
connections: Connection[];
connections: IConnection[];
connectedChannel: string | null;
}>({
connected: false,
@ -33,14 +36,18 @@ function useCurrent<T>(thing: T) {
return thingRef.current;
}
export interface Connection {
export interface IParticipant {
peerId: string;
clientId: string;
channelId: string;
}
export interface IConnection extends IParticipant {
call: MediaConnection | null;
isCaller: boolean;
mediaStream: MediaStream | null;
connected: boolean;
videoElement: HTMLVideoElement | null;
}
function isCaller(a: string, b:string) {
@ -59,7 +66,7 @@ export default function PeerState(props: any) {
const [incomingCalls, setIncomingCalls] = useState<MediaConnection[]>([]);
const [outgoingCalls, setOutgoingCalls] = useState<string[]>([]);
const [connections, setConnections] = useState<Connection[]>([]);
const [connections, setConnections] = useState<IConnection[]>([]);
const [channel, setChannel] = useState<string | null>(null);
const addIncomingCall = useCurrent(useCallback((call: MediaConnection) => {
@ -71,7 +78,7 @@ export default function PeerState(props: any) {
setIncomingCalls(calls => calls.filter(call => !peerIds.includes(call.peer)));
}
const updateConnection = (peerId: string, data: Partial<Connection>) => {
const updateConnection = (peerId: string, data: Partial<IConnection>) => {
setConnections(connections => connections.map(connection => {
if(connection.peerId !== peerId) return connection;
return {
@ -86,19 +93,63 @@ export default function PeerState(props: any) {
}
const destroyConnection = (peerId: string) => {
setConnections(connections => connections.filter(connection => {
if(connection.peerId !== peerId) return true;
if(connection.call) {
connection.call.close();
removeConnection(peerId);
setConnections(connections => {
const conn = connections.find(c => c.peerId === peerId)
if(conn && conn.call) {
conn.call.close();
}
}))
return connections;
})
removeConnection(peerId);
}
const addStream = (id: string, stream: MediaStream) => {
updateConnection(id, { mediaStream: stream, connected: true });
// DE BOUNCE THE INCOMING STREAMS, CAUSE WTF?!
setConnections(connections => {
const connection = connections.find(c => c.peerId === id);
if(!!connection && connection.mediaStream === null) {
return connections.map(connection => {
if(connection.peerId !== id) return connection;
if(connection.mediaStream !== null) return connection;
console.log('CREATED VIDEO ELEMENT');
const videoElement = document.createElement('video');
videoElement.srcObject = stream;
videoElement.autoplay = true;
videoElement.muted = true;
videoElement.style.height = '100%';
return {
...connection,
connected: true,
mediaStream: stream,
videoElement
}
})
} else {
return connections;
}
});
}
// replace mediastream in connections when mediaStream changes.
useEffect(() => {
if(mediaStream === null) return;
setConnections(connections => {
for(const conn of connections) {
if(conn.call === null) continue;
for(const sender of conn.call.peerConnection.getSenders()) {
if(sender.track === null) continue;
if(sender.track.kind === 'audio') {
sender.replaceTrack(mediaStream.getAudioTracks()[0]);
} else if(sender.track.kind === 'video') {
sender.replaceTrack(mediaStream.getVideoTracks()[0]);
}
}
}
return connections;
})
}, [mediaStream])
// accept / reject incoming calls
useEffect(() => {
if(incomingCalls.length === 0) return;
@ -130,19 +181,20 @@ export default function PeerState(props: any) {
}, [outgoingCalls, mediaStream, peer]);
const { send } = useApi({
'voice:join'(data: any) {
'voice:join'(data: IParticipant) {
if(data.channelId !== channel) return;
if(data.peerId === peerId) return;
if(peerId === null) return;
sfx.joinCall();
const newConn: Connection = {
const newConn: IConnection = {
call: null,
connected: false,
clientId: data.clientId,
peerId: data.peerId,
channelId: data.channelId,
isCaller: isCaller(peerId, data.peerId),
mediaStream: null
mediaStream: null,
videoElement: null
};
if(newConn.isCaller) {
setOutgoingCalls(c => [...c, data.peerId]);
@ -152,39 +204,44 @@ export default function PeerState(props: any) {
newConn
]))
},
'voice:leave'(data: any) {
'voice:leave'(data: IParticipant) {
sfx.leaveCall();
if(data.channelId !== channel) return;
if(data.peerId === peerId) return;
setConnections(connections => connections.filter(connection => (
connection.channelId !== data.channelId ||
connection.clientId !== data.clientId ||
connection.peerId !== data.peerId
)));
destroyConnection(data.peerId);
},
'voice:list'(data: any) {
'voice:list'(data: { uid: string, participants: IParticipant[]}) {
if(data.uid !== channel) return;
if(peerId === null) return;
if(connections.length !== 0) return;
setConnections(connections => {
console.log(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,
isCaller: caller
}
})
.filter((p) => p.peerId !== peerId)
.map((participant) => {
const previousCall = null;
const caller = isCaller(peerId, participant.peerId);
if(caller) {
setOutgoingCalls(c => [...c, participant.peerId]);
}
const newConnection: IConnection = {
...participant,
call: null,
isCaller: caller,
mediaStream: null,
connected: false,
videoElement: null
}
return newConnection
})
});
}
}, [channel, peerId]);
}, [channel, peerId, connections]);
useLog(connections[0], 'connections');
// create and maintain a peer connection
useEffect(() => {
if(connected) return;
if(peer !== null) return;
@ -212,6 +269,7 @@ export default function PeerState(props: any) {
const joinChannel = (channelId: string) => {
sfx.joinCall();
setChannel(channelId);
setConnections([]);
send('voice:list', { channelId });
}
@ -233,9 +291,12 @@ export default function PeerState(props: any) {
<div>
{connections.map(conn => (
(conn.mediaStream !== null) && (
<div key={conn.peerId}>
<Audio autoPlay hidden srcObject={conn.mediaStream}></Audio>
</div>
<Audio
key={conn.peerId}
autoPlay
hidden
srcObject={conn.mediaStream}
></Audio>
)
))}
</div>

View File

@ -1,4 +1,6 @@
import { createContext, useCallback, useMemo, useState } from "react";
import React from "react";
import { createContext, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Video } from "/@/components/Video";
export const UserMediaContext = createContext<{
enabled: boolean;
@ -11,6 +13,7 @@ export const UserMediaContext = createContext<{
enableCamera: () => void;
disableCamera: () => void;
cameraEnabled: boolean;
videoElement: HTMLVideoElement | null;
}>({
enabled: false,
mediaStream: null,
@ -22,6 +25,7 @@ export const UserMediaContext = createContext<{
enableCamera: () => {},
disableCamera: () => {},
cameraEnabled: false,
videoElement: null,
});
export default function UserMediaState(props: any) {
@ -30,27 +34,60 @@ export default function UserMediaState(props: any) {
const [enabled, setEnabled] = useState(false);
const [muted, setMuted] = useState(false);
const [cameraEnabled, setCameraEnabled] = useState(false);
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null);
const enable = useCallback(async () => {
const newStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
const createBlankVideoTrack = () => {
const canvas = document.createElement('canvas');
canvas.width = 40;
canvas.height = 30;
return canvas.captureStream(60).getVideoTracks()[0];
}
const updateMediaStream = (mediaStream: MediaStream | null) => {
setMediaStream(old => {
if(old !== null) {
for(const track of old.getTracks()) {
track.stop();
}
}
return mediaStream;
});
setMediaStream(newStream);
setEnabled(true);
}, []);
const disable = useCallback(async () => {
if(mediaStream === null) return;
for(const track of mediaStream?.getTracks()) {
track.stop();
if(mediaStream !== null) {
const videoElement = document.createElement('video');
videoElement.muted = true;
videoElement.autoplay = true;
videoElement.srcObject = mediaStream;
videoElement.style.height = '100%';
setVideoElement(videoElement);
} else {
setVideoElement(null);
}
}
setMediaStream(null);
setEnabled(false);
}, [mediaStream]);
// maintaining the mediaStream...
useEffect(() => {
(async () => {
if(enabled) {
const newStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: cameraEnabled,
});
if(!cameraEnabled) {
newStream.addTrack(createBlankVideoTrack());
}
if(muted) {
newStream.getAudioTracks()[0].enabled = false;
}
updateMediaStream(newStream);
} else {
updateMediaStream(null);
}
})()
}, [enabled, cameraEnabled]);
const mute = () => {
if(mediaStream === null) return;
@ -67,14 +104,16 @@ export default function UserMediaState(props: any) {
const value = useMemo(() => ({
enabled,
mediaStream,
enable,
disable,
enable: () => setEnabled(true),
disable: () => setEnabled(false),
mute,
unmute,
muted
}), [enabled, mediaStream, enable, disable, muted]);
muted,
enableCamera: () => setCameraEnabled(true),
disableCamera: () => setCameraEnabled(false),
cameraEnabled,
videoElement
}), [enabled, mediaStream, muted]);
return <UserMediaContext.Provider value={value}>
{props.children}

View File

@ -1,12 +1,14 @@
import { useContext, useEffect } from 'react';
import { useContext, useEffect, useMemo } from 'react';
import { ServerConnectionContext } from '../components/ServerConnection';
import useSessionToken from '../hooks/useSessionToken';
import { Router, router, RouterObject } from './api';
import { v4 } from 'uuid';
export function useApi(actions: Router | RouterObject = {}, deps: any[] = []) {
const connection = useContext(ServerConnectionContext);
const _router = typeof actions === 'object' ? router(actions) : actions;
const { sessionToken } = useSessionToken();
const componentId = useMemo(() => { return v4() }, []);
useEffect(() => {
connection.registerRouter(_router);
@ -23,7 +25,8 @@ export function useApi(actions: Router | RouterObject = {}, deps: any[] = []) {
}
connection.send(action, {
...(data ?? {}),
sessionToken
sessionToken,
$componentId: componentId
});
}
};