feat: send and receive stuff kinda done (moving to redis)

This commit is contained in:
2025-10-29 19:38:10 +01:00
parent ba9103a1ad
commit 3549cf737a
16 changed files with 440 additions and 29 deletions

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from "vue"
import {
PinInput,
PinInputGroup,
PinInputSlot,
} from "@/components/ui/pin-input"
import { useViewerStore } from "~/state/viewer";
const viewerStore = useViewerStore()
</script>
<template>
<div>
<PinInput
id="pin-input"
@complete="(viewerStore.code = $event.join(''))"
>
<PinInputGroup>
<PinInputSlot
v-for="(id, index) in 6"
:key="id"
:index="index"
/>
</PinInputGroup>
</PinInput>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts" generic="Type extends 'text' | 'number' = 'text'">
import type { PinInputRootEmits, PinInputRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { PinInputRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<PinInputRootProps<Type> & { class?: HTMLAttributes["class"] }>(), {
modelValue: () => [],
})
const emits = defineEmits<PinInputRootEmits<Type>>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PinInputRoot
data-slot="pin-input"
v-bind="forwarded" :class="cn('flex items-center gap-2 has-disabled:opacity-50 disabled:cursor-not-allowed', props.class)"
>
<slot />
</PinInputRoot>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Primitive, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<Primitive
data-slot="pin-input-group"
v-bind="forwardedProps"
:class="cn('flex items-center', props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import { Minus } from "lucide-vue-next"
import { Primitive, useForwardProps } from "reka-ui"
const props = defineProps<PrimitiveProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<Primitive
data-slot="pin-input-separator"
v-bind="forwardedProps"
>
<slot>
<Minus />
</slot>
</Primitive>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { PinInputInputProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { PinInputInput, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<PinInputInputProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<PinInputInput
data-slot="pin-input-slot"
v-bind="forwardedProps"
:class="cn('border-input focus:border-ring focus:ring-ring/50 focus:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:focus:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus:aria-invalid:border-destructive relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none text-center first:rounded-l-md first:border-l last:rounded-r-md focus:z-10 focus:ring-[3px]', props.class)"
/>
</template>

View File

@@ -0,0 +1,4 @@
export { default as PinInput } from "./PinInput.vue"
export { default as PinInputGroup } from "./PinInputGroup.vue"
export { default as PinInputSeparator } from "./PinInputSeparator.vue"
export { default as PinInputSlot } from "./PinInputSlot.vue"

View File

@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { ToasterProps } from "vue-sonner"
import { Toaster as Sonner } from "vue-sonner"
const props = defineProps<ToasterProps>()
</script>
<template>
<Sonner
class="toaster group"
v-bind="props"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
}"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./Sonner.vue"

View File

@@ -1,3 +1,78 @@
<script setup lang="ts">
import { useWebSocket } from '@vueuse/core';
import { useViewerStore } from '~/state/viewer';
import { Button } from "@/components/ui/button"
import { toast } from 'vue-sonner';
const viewerStore = useViewerStore()
const { code: codeRef } = storeToRefs(viewerStore)
const { send, data } = useWebSocket('ws://localhost:3000/ws/signaling', {
autoReconnect: true,
//heartbeat: true,
onMessage: async (ws, ev) => {
const message = JSON.parse(ev.data)
if (message.event === 'joined') {
toast.success('stweam joined successfullay')
}
if (message.event === 'offer') {
const peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
await peerConnection.setRemoteDescription(message.sdp);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
send(JSON.stringify({
event: 'answer',
targetId: message.data.streamerId,
sdp: answer,
}))
peerConnection.ontrack = (event) => {
if (event.streams && event.streams[0] && videofeedRef.value) {
videofeedRef.value.srcObject = event.streams[0];
}
};
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
send(JSON.stringify({
event: 'ice-candidate',
targetId: message.data.streamerId,
candidate: event.candidate,
}))
}
};
}
},
});
const videofeedRef = ref<HTMLVideoElement|null>(null);
const startWebRTCConnection = async () => {
send(JSON.stringify({
event: 'join-room',
data: {
roomId: viewerStore.code,
}
}))
}
watch(codeRef, (newCode) => {
// sort of a safeguard bc only 6 digit codes end up getting passed
if (newCode.length === 6) {
startWebRTCConnection();
}
})
</script>
<template>
<h1>Index page</h1>
<h1>helium</h1>
<p>effortless screensharing powered by webrtc</p>
<p>code is {{ viewerStore.code }}</p>
<app-code-input />
<video ref="videofeedRef" autoplay playsinline muted></video>
<NuxtLink to="/stream"><Button>host instead?</Button></NuxtLink>
</template>

72
app/pages/stream.vue Normal file
View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { useWebSocket } from '@vueuse/core';
import { Button } from "@/components/ui/button"
import { useStreamerStore } from '~/state/streamer';
const streamerStore = useStreamerStore()
const videofeedRef = ref<HTMLVideoElement|null>(null);
const { send, data, ws } = useWebSocket('ws://localhost:3000/ws/signaling', {
autoReconnect: true,
//heartbeat: true,
onMessage: async (ws, ev) => {
const message = JSON.parse(ev.data)
if (message.event === 'room-created') {
const roomId = message.roomId
streamerStore.setCode(roomId)
}
if (message.event === 'viewer-joined') {
const peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
streamerStore.addPeerConnection(message.data.viewerId, peerConnection)
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
send(JSON.stringify({
event: 'offer',
targetId: message.data.viewerId,
sdp: offer,
}))
}
if (message.event === 'ice-candidate') {
ws.send(JSON.stringify({
event: 'ice-candidate',
targetId: message.data.senderId,
candidate: message.data.candidate,
}))
}
if (message.event === 'answer') {
const pc = streamerStore.peerConnections[message.data.viewerId];
if (!pc) {
console.error('peerconnection not found for peerid: ', message.data.viewerId);
return;
};
const remoteDesc = new RTCSessionDescription(message.data.sdp);
await pc.setRemoteDescription(remoteDesc);
}
},
});
async function startScreenShare() {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
if (videofeedRef.value) {
videofeedRef.value.srcObject = stream;
}
send(JSON.stringify({
event: 'create-room',
}))
}
</script>
<template>
<Button @click="startScreenShare">
screenshare
</Button>
<p>Your stream code: {{ streamerStore.code }}</p>
<video ref="videofeedRef" autoplay playsinline muted></video>
</template>

16
app/state/streamer.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineStore} from 'pinia';
export const useStreamerStore = defineStore('streamer', {
state: () => ({
code: '',
peerConnections: {} as Record<string, RTCPeerConnection>,
}),
actions: {
setCode(code: string) {
this.code = code;
},
addPeerConnection(id: string, pc: RTCPeerConnection) {
this.peerConnections[id] = pc;
},
},
});

12
app/state/viewer.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineStore} from 'pinia';
export const useViewerStore = defineStore('viewer', {
state: () => ({
code: '',
}),
actions: {
setCode(code: string) {
this.code = code;
},
},
});

View File

@@ -10,7 +10,7 @@ export default defineNuxtConfig({
tailwindcss(),
],
},
modules: ['shadcn-nuxt', '@nuxtjs/color-mode'],
modules: ['shadcn-nuxt', '@nuxtjs/color-mode', '@pinia/nuxt'],
colorMode: {
classSuffix: ''
},

View File

@@ -11,24 +11,28 @@
"ui:add": "pnpm dlx shadcn-vue@latest add"
},
"dependencies": {
"@pinia/nuxt": "0.11.2",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.548.0",
"nuxt": "^4.2.0",
"pinia": "^3.0.3",
"reka-ui": "^2.6.0",
"shadcn-nuxt": "2.3.2",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@iconify-json/radix-icons": "^1.2.5",
"@iconify/vue": "^5.0.0",
"@nuxtjs/color-mode": "^3.5.2",
"nuxi": "^3.29.3",
"typescript": "^5.9.3"
}
}

74
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@pinia/nuxt':
specifier: 0.11.2
version: 0.11.2(magicast@0.5.0)(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)))
'@tailwindcss/vite':
specifier: ^4.1.16
version: 4.1.16(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
@@ -26,6 +29,9 @@ importers:
nuxt:
specifier: ^4.2.0
version: 4.2.0(@parcel/watcher@2.5.1)(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(rollup@4.52.5)(terser@5.44.0)(typescript@5.9.3)(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)
pinia:
specifier: ^3.0.3
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
reka-ui:
specifier: ^2.6.0
version: 2.6.0(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
@@ -47,6 +53,9 @@ importers:
vue-router:
specifier: ^4.6.3
version: 4.6.3(vue@3.5.22(typescript@5.9.3))
vue-sonner:
specifier: ^2.0.9
version: 2.0.9(@nuxt/kit@4.2.0(magicast@0.5.0))(@nuxt/schema@4.2.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(rollup@4.52.5)(terser@5.44.0)(typescript@5.9.3)(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1))
devDependencies:
'@iconify-json/radix-icons':
specifier: ^1.2.5
@@ -57,6 +66,9 @@ importers:
'@nuxtjs/color-mode':
specifier: ^3.5.2
version: 3.5.2(magicast@0.5.0)
nuxi:
specifier: ^3.29.3
version: 3.29.3
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -950,6 +962,11 @@ packages:
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@pinia/nuxt@0.11.2':
resolution: {integrity: sha512-CgvSWpbktxxWBV7ModhAcsExsQZqpPq6vMYEe9DexmmY6959ev8ukL4iFhr/qov2Nb9cQAWd7niFDnaWkN+FHg==}
peerDependencies:
pinia: ^3.0.3
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1355,6 +1372,9 @@ packages:
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.7':
resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==}
'@vue/devtools-core@7.7.7':
resolution: {integrity: sha512-9z9TLbfC+AjAi1PQyWX+OErjIaJmdFlbDHcD+cAMYKY6Bh5VlsAtCeGyRMrXwIlMEQPukvnWt3gZBLwTAIMKzQ==}
peerDependencies:
@@ -2519,6 +2539,11 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nuxi@3.29.3:
resolution: {integrity: sha512-+/IHFCXT2t1XO5dQdpVyV/eU12DrVOCkKKwrDv9YwHIL4pXMyTDxIGMfU995YWjLjRb7SUsfNLfGV4wWCFdg0A==}
engines: {node: ^16.10.0 || >=18.0.0}
hasBin: true
nuxt@4.2.0:
resolution: {integrity: sha512-4qzf2Ymf07dMMj50TZdNZgMqCdzDch8NY3NO2ClucUaIvvsr6wd9+JrDpI1CckSTHwqU37/dIPFpvIQZoeHoYA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2650,6 +2675,15 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pinia@3.0.3:
resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@@ -3469,6 +3503,20 @@ packages:
peerDependencies:
vue: ^3.5.0
vue-sonner@2.0.9:
resolution: {integrity: sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==}
peerDependencies:
'@nuxt/kit': ^4.0.3
'@nuxt/schema': ^4.0.3
nuxt: ^4.0.3
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@nuxt/schema':
optional: true
nuxt:
optional: true
vue@3.5.22:
resolution: {integrity: sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==}
peerDependencies:
@@ -4540,6 +4588,13 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1
'@pinia/nuxt@0.11.2(magicast@0.5.0)(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)))':
dependencies:
'@nuxt/kit': 3.20.0(magicast@0.5.0)
pinia: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
transitivePeerDependencies:
- magicast
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -4912,6 +4967,10 @@ snapshots:
'@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.7':
dependencies:
'@vue/devtools-kit': 7.7.7
'@vue/devtools-core@7.7.7(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 7.7.7
@@ -6143,6 +6202,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuxi@3.29.3: {}
nuxt@4.2.0(@parcel/watcher@2.5.1)(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(rollup@4.52.5)(terser@5.44.0)(typescript@5.9.3)(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1):
dependencies:
'@dxup/nuxt': 0.2.0(magicast@0.5.0)
@@ -6428,6 +6489,13 @@ snapshots:
picomatch@4.0.3: {}
pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.7
vue: 3.5.22(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -7229,6 +7297,12 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.22(typescript@5.9.3)
vue-sonner@2.0.9(@nuxt/kit@4.2.0(magicast@0.5.0))(@nuxt/schema@4.2.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(rollup@4.52.5)(terser@5.44.0)(typescript@5.9.3)(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)):
optionalDependencies:
'@nuxt/kit': 4.2.0(magicast@0.5.0)
'@nuxt/schema': 4.2.0
nuxt: 4.2.0(@parcel/watcher@2.5.1)(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(rollup@4.52.5)(terser@5.44.0)(typescript@5.9.3)(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)
vue@3.5.22(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.22

View File

@@ -1,30 +1,49 @@
// thanks nitropack smh
type Peer = Parameters<NonNullable<Parameters<typeof defineWebSocketHandler>[0]['message']>>[0];
const rooms: Record<string, { broadcaster: Peer, viewers: Peer[] }> = {};
// Store peer instances in memory (these can't be serialized to storage)
const activePeers = new Map<string, Peer>();
async function getRooms() {
return await useStorage('rooms').getItem<Record<string, { broadcaster: string, viewers: string[] }>>('data') || {};
}
async function saveRooms(rooms: Record<string, { broadcaster: string, viewers: string[] }>) {
await useStorage('rooms').setItem('data', rooms);
}
export default defineWebSocketHandler({
message(peer, message) {
async open(peer) {
activePeers.set(peer.id, peer);
},
async message(peer, message) {
// TODO: proper typing
const msg = message.json() as any;
console.log("[ws] message", peer.id, msg);
const rooms = await getRooms();
if (msg.event === 'create-room') {
const roomId = generateRoomId();
rooms[roomId] = { broadcaster: peer, viewers: [] };
rooms[roomId] = { broadcaster: peer.id, viewers: [] };
await saveRooms(rooms);
peer.send(JSON.stringify({ event: 'room-created', roomId }));
}
if (msg.event === 'join-room') {
const room = rooms[msg.roomId];
if (room) {
room.viewers.push(peer);
room.broadcaster.send(JSON.stringify({ event: 'viewer-joined', viewerId: peer.id }));
room.viewers.push(peer.id);
await saveRooms(rooms);
const broadcasterPeer = activePeers.get(room.broadcaster);
if (broadcasterPeer) {
broadcasterPeer.send(JSON.stringify({ event: 'viewer-joined', viewerId: peer.id }));
}
} else {
peer.send(JSON.stringify({ event: 'error', message: 'Room not found' }));
}
}
if (msg.event === 'offer') {
const viewerSocket = findSocketById(msg.targetId);
const viewerSocket = activePeers.get(msg.targetId);
if (viewerSocket) {
viewerSocket.send(JSON.stringify({
event: 'offer',
@@ -34,17 +53,17 @@ export default defineWebSocketHandler({
}
}
if (msg.event === 'answer') {
const broadcasterSocket = findSocketById(msg.targetId)
const broadcasterSocket = activePeers.get(msg.targetId);
if (broadcasterSocket) {
broadcasterSocket.send({
broadcasterSocket.send(JSON.stringify({
event: 'answer',
sdp: msg.sdp,
from: peer.id,
})
}));
}
}
if (msg.event === 'ice-candidate') {
const targetSocket = findSocketById(msg.targetId);
const targetSocket = activePeers.get(msg.targetId);
if (targetSocket) {
targetSocket.send(JSON.stringify({
event: 'ice-candidate',
@@ -55,35 +74,36 @@ export default defineWebSocketHandler({
}
},
close(peer, event) {
async close(peer, event) {
console.log("[ws] close", peer.id, event);
activePeers.delete(peer.id);
const rooms = await getRooms();
for (const [roomId, room] of Object.entries(rooms)) {
if (room.broadcaster.id === peer.id) {
if (room.broadcaster === peer.id) {
// broadcaster disconnected, close room
room.viewers.forEach(viewer => {
viewer.send(JSON.stringify({ event: 'room-closed' }));
room.viewers.forEach(viewerId => {
const viewer = activePeers.get(viewerId);
if (viewer) {
viewer.send(JSON.stringify({ event: 'room-closed' }));
}
});
delete rooms[roomId];
} else {
const viewerIndex = room.viewers.findIndex(v => v.id === peer.id);
const viewerIndex = room.viewers.indexOf(peer.id);
if (viewerIndex !== -1) {
room.viewers.splice(viewerIndex, 1);
room.broadcaster.send(JSON.stringify({ event: 'viewer-left', viewerId: peer.id }));
const broadcasterPeer = activePeers.get(room.broadcaster);
if (broadcasterPeer) {
broadcasterPeer.send(JSON.stringify({ event: 'viewer-left', viewerId: peer.id }));
}
}
}
}
await saveRooms(rooms);
},
});
function generateRoomId(): string {
return Math.random().toString(36).substring(2, 8).toUpperCase();
}
function findSocketById(id: string): Peer | null {
for (const room of Object.values(rooms)) {
if (room.broadcaster.id === id) return room.broadcaster;
const viewer = room.viewers.find(v => v.id === id);
if (viewer) return viewer;
}
return null;
return Math.random().toString().slice(2, 8);
}