Skip to content

Commit

Permalink
feat: webrtc
Browse files Browse the repository at this point in the history
  • Loading branch information
stagas committed Oct 8, 2024
1 parent 32dabc4 commit 7b79bb4
Show file tree
Hide file tree
Showing 10 changed files with 274 additions and 11 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
- [x] WebAudio
- [x] Wasm AudioWorklet
- [ ] WebGL
- [ ] WebRTC
- [x] WebRTC
- [x] QRCode
- [ ] Maps
- [x] Testing
Expand Down
20 changes: 19 additions & 1 deletion api/chat/actions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// deno-lint-ignore-file require-await
import { UserSession } from '~/api/auth/types.ts'
import { bus } from "~/api/chat/bus.ts"
import { broadcast, subs } from "~/api/chat/routes.ts"
import { ChatChannel, ChatMessage, ChatMessageType, UiChannel } from "~/api/chat/types.ts"
import { ChatChannel, ChatDirectMessage, ChatDirectMessageType, ChatMessage, ChatMessageType, UiChannel } from "~/api/chat/types.ts"
import { createBus } from '~/api/core/create-bus.ts'
import { Context } from '~/api/core/router.ts'
import { getSession } from '~/api/core/sessions.ts'
Expand Down Expand Up @@ -139,6 +140,23 @@ export async function sendMessageToChannel(ctx: Context, type: ChatMessageType,
return message
}

actions.post.sendMessageToUser = sendMessageToUser
export async function sendMessageToUser(ctx: Context, type: ChatDirectMessageType, targetNick: string, text: string = '') {
const { nick } = getSession(ctx)
if (nick === targetNick) return

const msg: ChatDirectMessage = {
type,
nick,
text
}

const sub = subs.get(targetNick)
if (sub) {
sub.send(msg)
}
}

actions.post.joinChannel = joinChannel
export async function joinChannel(ctx: Context, channel: string) {
const session = getSession(ctx)
Expand Down
12 changes: 12 additions & 0 deletions api/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@ export interface ChatMessage {
nick: string
text: string
}

export type ChatDirectMessageType =
| 'directMessage'
| 'webrtc:offer'
| 'webrtc:answer'
| 'webrtc:end'

export interface ChatDirectMessage {
type: ChatDirectMessageType
nick: string
text: string
}
4 changes: 2 additions & 2 deletions api/ws/routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createBus } from '~/api/core/create-bus.ts'
import { Router } from '~/api/core/router.ts'
import type { Router } from '~/api/core/router.ts'
import { getSession } from '~/api/core/sessions.ts'

const clients = new Set<WebSocket>()
Expand All @@ -26,7 +26,7 @@ export function mount(app: Router) {

ctx.log('[ws] connecting...', nick)

const { socket: ws, response } = Deno.upgradeWebSocket(ctx.request, {
const { response, socket: ws } = Deno.upgradeWebSocket(ctx.request, {
idleTimeout: 0
})

Expand Down
64 changes: 60 additions & 4 deletions src/pages/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
import { Sigui } from 'sigui'
import type { ChatMessage } from '~/api/chat/types.ts'
import { dispose, Sigui } from 'sigui'
import type { ChatDirectMessage, ChatMessage } from '~/api/chat/types.ts'
import { Channels } from '~/src/pages/Chat/Channels.tsx'
import { Messages } from '~/src/pages/Chat/Messages.tsx'
import { Users } from '~/src/pages/Chat/Users.tsx'
import { byName, byNick, hasChannel } from '~/src/pages/Chat/util.ts'
import { VideoCall } from '~/src/pages/Chat/VideoCall.tsx'
import * as actions from '~/src/rpc/chat.ts'
import { screen } from '~/src/screen.ts'
import { state } from '~/src/state.ts'
import { go } from '~/src/ui/Link.tsx'

export interface RemoteSdp {
type: 'webrtc:offer' | 'webrtc:answer'
nick: string
text: string
}

export function Chat() {
using $ = Sigui()

const info = $({
started: null as null | true,

showChannelsOverlay: false,

videoCallType: null as null | 'offer' | 'answer',
videoCallTargetNick: null as null | string,
remoteSdp: null as null | RemoteSdp,
})

actions.listChannels().then(channels => {
Expand All @@ -31,7 +43,7 @@ export function Chat() {
})

chat.onmessage = ({ data }) => {
const msg = JSON.parse(data) as ChatMessage
const msg = JSON.parse(data) as ChatMessage | ChatDirectMessage

switch (msg.type) {
case 'started':
Expand Down Expand Up @@ -59,6 +71,29 @@ export function Chat() {
if (channel.users.find(u => u.nick === msg.nick)) return
channel.users = [...channel.users, { nick: msg.nick }].sort(byNick)
}
break
}

case 'directMessage': {
alert(msg.text)
break
}

case 'webrtc:offer': {
info.remoteSdp = msg as RemoteSdp
info.videoCallType = 'answer'
info.videoCallTargetNick = msg.nick
break
}

case 'webrtc:answer': {
info.remoteSdp = msg as RemoteSdp
break
}

case 'webrtc:end': {
info.videoCallTargetNick = null
break
}
}
}
Expand All @@ -68,6 +103,14 @@ export function Chat() {
}
})

$.fx(() => {
const { videoCallTargetNick } = $.of(info)
$()
return () => {
actions.sendMessageToUser('webrtc:end', videoCallTargetNick)
}
})

$.fx(() => {
const { started } = $.of(info)
const { channelsList, currentChannelName } = state
Expand Down Expand Up @@ -112,6 +155,19 @@ export function Chat() {
: <div />
}
<Messages showChannelsOverlay={info.$.showChannelsOverlay} />
{() => screen.md ? <Users /> : <div />}
{() => screen.md ? <Users onUserClick={nick => {
info.videoCallType = 'offer'
info.videoCallTargetNick = nick
}} /> : <div />}

{() => dispose() && info.videoCallType && info.videoCallTargetNick
?
<VideoCall
type={info.$.videoCallType}
targetNick={info.$.videoCallTargetNick}
remoteSdp={info.$.remoteSdp} />
:
<div />
}
</div>
}
21 changes: 19 additions & 2 deletions src/pages/Chat/Users.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import { refs, Sigui } from 'sigui'
import { colorizeNick } from '~/src/pages/Chat/util.ts'
import * as actions from '~/src/rpc/chat.ts'
import { state } from '~/src/state.ts'
import { H3 } from '~/src/ui/Heading.tsx'
import { Link } from '~/src/ui/Link.tsx'

export function Users({ onUserClick }: {
onUserClick(nick: string): void
}) {
if (!state.user) return <div />

const { nick } = state.user

export function Users() {
return <div class="w-[30%] max-w-56 flex flex-col gap-2 pt-1.5 pb-2.5 pl-4 ml-4 flex-grow border-l border-l-neutral-700">
<H3>Users</H3>

<div class="overflow-y-scroll leading-[19px]">
{() => state.currentChannel?.users.map(user =>
<div class="flex items-center gap-1">
<span style={{ color: colorizeNick(user.nick) }}>{user.nick}</span>
{nick !== user.nick ?
<Link
style={{ color: colorizeNick(user.nick) }}
onclick={() => onUserClick(user.nick)}
>
{user.nick}
</Link>
: <span style={{ color: colorizeNick(user.nick) }}>{user.nick}</span>
}
</div>
)}
</div>
Expand Down
156 changes: 156 additions & 0 deletions src/pages/Chat/VideoCall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Mic, MicOff, PhoneOff, Video, VideoOff } from 'lucide'
import { Sigui, refs, type Signal } from 'sigui'
import { icon } from '~/lib/icon.ts'
import type { RemoteSdp } from '~/src/pages/Chat/Chat.tsx'
import * as actions from '~/src/rpc/chat.ts'

const pcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
],
}

function VideoButton(props: Record<string, any>) {
return <button class="border-none bg-white-500 bg-opacity-40 hover:bg-opacity-80 cursor-pointer" onclick={props.onclick}>
{props.children}
</button>
}

export function VideoCall({ type, targetNick, remoteSdp }: {
type: Signal<null | 'offer' | 'answer'>,
targetNick: Signal<null | string>,
remoteSdp: Signal<RemoteSdp | null>
}) {
using $ = Sigui()

const info = $({
type,
targetNick,
remoteSdp,

localVideoStream: null as null | MediaStream,
remoteVideoStream: null as null | MediaStream,
pc: null as null | RTCPeerConnection,

cameraOn: true,
micOn: true,
})

const el = <div class='top-0 left-0 absolute flex w-[100vw] h-[100vh]'>
<div class='w-[70vw] relative h-[calc(100%-10vh)] m-auto p-4 border-8 border-neutral-700 bg-black flex items-center justify-center'>
<video
ref='remoteVideo'
class="h-full"
srcObject={() => info.remoteVideoStream}
style="image-rendering: pixelated"
autoplay
/>
<div class="absolute z-10 w-full right-0 bottom-0 p-8 flex items-end justify-between">
<div class="flex justify-between gap-1">
<VideoButton onclick={() => info.cameraOn = !info.cameraOn}>
{() => icon(info.cameraOn ? Video : VideoOff)}
</VideoButton>
<VideoButton onclick={() => info.micOn = !info.micOn}>
{() => icon(info.micOn ? Mic : MicOff)}
</VideoButton>
<VideoButton onclick={() => info.type = info.targetNick = null}>
{icon(PhoneOff)}
</VideoButton>
</div>
<video
ref='localVideo'
srcObject={() => info.localVideoStream}
autoplay
muted
style="image-rendering: pixelated"
class='w-[20vw] border-8 border-neutral-700 pointer-events-none'
/>
</div>
</div>
</div>

const localVideo = refs.localVideo as HTMLVideoElement

async function startLocalVideo() {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { exact: 100 },
height: { exact: 50 },
},
audio: true,
})
info.localVideoStream = stream
await localVideo.play()
}

startLocalVideo()

function createPc(stream: MediaStream) {
const pc = new RTCPeerConnection(pcConfig)
pc.addEventListener('track', (ev) => {
if (ev.track.kind === 'video') {
info.remoteVideoStream = ev.streams[0]
}
})
for (const track of stream.getTracks()) {
pc.addTrack(track, stream)
}
return pc
}

$.fx(() => {
const { type, localVideoStream: stream, targetNick } = $.of(info)
$()
if (type === 'offer') {
const pc = info.pc = createPc(stream)
pc.createOffer()
.then(offer => {
pc.setLocalDescription(offer)
actions.sendMessageToUser('webrtc:offer', targetNick, offer.sdp)
})
}
})

$.fx(() => {
const { type, localVideoStream: stream, targetNick, remoteSdp } = $.of(info)
$()
if (type === 'answer') {
const pc = createPc(stream)
const theirOffer = new RTCSessionDescription({
type: 'offer',
sdp: remoteSdp.text,
})
pc.setRemoteDescription(theirOffer)
pc.createAnswer()
.then(answer => {
pc.setLocalDescription(answer)
pc.addEventListener('icecandidate', (ev) => {
if (ev.candidate !== null) return
actions.sendMessageToUser('webrtc:answer', targetNick, pc.localDescription!.sdp)
})
})
}
else if (type === 'offer') {
const theirAnswer = new RTCSessionDescription({
type: 'answer',
sdp: remoteSdp.text,
})
info.pc?.setRemoteDescription(theirAnswer)
}
})

$.fx(() => () => {
info.localVideoStream?.getTracks().forEach(track => track.stop())
info.remoteVideoStream?.getTracks().forEach(track => track.stop())
info.pc?.close()
info.remoteSdp =
info.localVideoStream =
info.remoteVideoStream =
info.pc =
null
})

return el
}
1 change: 1 addition & 0 deletions src/pages/UiShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function UiShowcase() {
<UiGroup name="Link">
<Link href="#">About</Link>
</UiGroup>

</div>
)
}
1 change: 1 addition & 0 deletions src/rpc/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export const deleteChannel = rpc<typeof actions.deleteChannel>('POST', 'deleteCh
export const getChannel = rpc<typeof actions.getChannel>('GET', 'getChannel')
export const joinChannel = rpc<typeof actions.joinChannel>('POST', 'joinChannel')
export const sendMessageToChannel = rpc<typeof actions.sendMessageToChannel>('POST', 'sendMessageToChannel')
export const sendMessageToUser = rpc<typeof actions.sendMessageToUser>('POST', 'sendMessageToUser')
Loading

0 comments on commit 7b79bb4

Please sign in to comment.