mirror of
https://github.com/SrIzan10/helium.git
synced 2026-06-06 00:56:58 +00:00
feat: send and receive stuff kinda done (moving to redis)
This commit is contained in:
28
app/components/app/CodeInput.vue
Normal file
28
app/components/app/CodeInput.vue
Normal 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>
|
||||
25
app/components/ui/pin-input/PinInput.vue
Normal file
25
app/components/ui/pin-input/PinInput.vue
Normal 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>
|
||||
21
app/components/ui/pin-input/PinInputGroup.vue
Normal file
21
app/components/ui/pin-input/PinInputGroup.vue
Normal 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>
|
||||
19
app/components/ui/pin-input/PinInputSeparator.vue
Normal file
19
app/components/ui/pin-input/PinInputSeparator.vue
Normal 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>
|
||||
21
app/components/ui/pin-input/PinInputSlot.vue
Normal file
21
app/components/ui/pin-input/PinInputSlot.vue
Normal 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>
|
||||
4
app/components/ui/pin-input/index.ts
Normal file
4
app/components/ui/pin-input/index.ts
Normal 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"
|
||||
19
app/components/ui/sonner/Sonner.vue
Normal file
19
app/components/ui/sonner/Sonner.vue
Normal 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>
|
||||
1
app/components/ui/sonner/index.ts
Normal file
1
app/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./Sonner.vue"
|
||||
@@ -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
72
app/pages/stream.vue
Normal 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
16
app/state/streamer.ts
Normal 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
12
app/state/viewer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineStore} from 'pinia';
|
||||
|
||||
export const useViewerStore = defineStore('viewer', {
|
||||
state: () => ({
|
||||
code: '',
|
||||
}),
|
||||
actions: {
|
||||
setCode(code: string) {
|
||||
this.code = code;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export default defineNuxtConfig({
|
||||
tailwindcss(),
|
||||
],
|
||||
},
|
||||
modules: ['shadcn-nuxt', '@nuxtjs/color-mode'],
|
||||
modules: ['shadcn-nuxt', '@nuxtjs/color-mode', '@pinia/nuxt'],
|
||||
colorMode: {
|
||||
classSuffix: ''
|
||||
},
|
||||
|
||||
@@ -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
74
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user