first commit
CI / Type-check & Build (push) Failing after 5m50s
CI / Build & Push image (push) Skipped

This commit is contained in:
ysj7705 committed 2026-08-12 15:01:43 +09:00
commit 69b647145f
58 files changed
+15796

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL="file:../data/sejong.db"
JWT_SECRET="change-me-to-a-long-random-string"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="change-me"
MAX_ROOM_MEMBERS=8
MAX_CONCURRENT_STREAMS=4
ROOM_ACCESS_TTL_DAYS=7
+91
View File
@@ -0,0 +1,91 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
name: Type-check & Build
runs-on: ubuntu-latest
env:
DATABASE_URL: file:./data/sejong.db
JWT_SECRET: ci-build-placeholder
ADMIN_USERNAME: admin
ADMIN_PASSWORD: admin
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Install dependencies
run: npm ci
- name: Prisma migrate (sqlite for build)
run: |
mkdir -p data
npx prisma migrate deploy
- name: Type-check and build
run: npm run build
image:
name: Build & Push image
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Job image often has no docker binary; host sock may already be mounted by act_runner.
- name: Install Docker CLI
run: |
mkdir -p "$HOME/.local/bin"
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz \
| tar xz --strip-components=1 -C "$HOME/.local/bin" docker/docker
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
if [ -S /var/run/docker.sock ]; then
docker info
docker version
exit 0
fi
echo "Host /var/run/docker.sock is not available in this job."
echo "On act_runner, set container.docker_host: \"\" (use host Docker)"
echo "and/or container.valid_volumes: [/var/run/docker.sock]"
ls -la /var/run/docker.sock || true
docker info || true
exit 1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: docker.sejong.dev
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
docker.sejong.dev/sejong-tv:latest
docker.sejong.dev/sejong-tv:sha-${{ github.sha }}
- name: Log out of registry
if: always()
run: docker logout docker.sejong.dev
+24
View File
@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.nuxt
.output
.data
.nitro
dist
# Node
node_modules
*.log*
.DS_Store
# Env
.env
.env.*
!.env.example
# Database
data/*.db
data/*.db-journal
# IDE
.idea
.vscode
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
COPY prisma ./prisma
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV DATABASE_URL="file:./data/sejong.db"
RUN mkdir -p data && npx prisma generate && npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000
ENV DATABASE_URL="file:/app/data/sejong.db"
COPY --from=build /app/.output ./.output
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/prisma ./prisma
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x docker-entrypoint.sh && mkdir -p /app/data
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
+42
View File
@@ -0,0 +1,42 @@
# Sejong TV
Nuxt 3 기반 멀티 스크린 화면 공유 플랫폼 (Native WebRTC P2P Mesh + Socket.io + Prisma/SQLite).
## 빠른 시작 (로컬)
```bash
cp .env.example .env
npm install
npx prisma migrate dev
npm run dev
```
- 앱: http://localhost:3000
- 관리자: http://localhost:3000/admin (기본 `admin` / `admin` — `.env`에서 변경)
## Docker + NPM Plus
레지스트리 이미지 예:
```bash
docker pull docker.sejong.dev/sejong-tv:latest
```
로컬 빌드:
```bash
docker compose up -d --build
```
프록시 설정은 [docs/npmplus.md](docs/npmplus.md) 참고.
Gitea CI: `main` 푸시 시 `docker.sejong.dev/sejong-tv:latest` 및 `sha-<commit>` 태그로 푸시합니다.
시크릿 `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`가 필요합니다.
## 주요 기능
- 관리자 방 생성/삭제 (영속, 방 비밀번호)
- URL 입장 + clientId/accessToken 재입장
- N:N 화면 공유, 자율 시청, 스트림별 볼륨/음소거
- Who-Watches-Whom 실시간 표시
- 방 인원 최대 8, 동시 방송 최대 4
+5
View File
@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
+76
View File
@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg: #0c1117;
--bg-elevated: #151b24;
--bg-panel: #1a222e;
--border: #2a3544;
--text: #e8eef6;
--text-muted: #8b9bb0;
--accent: #3d8bfd;
--accent-soft: rgba(61, 139, 253, 0.15);
--danger: #f07178;
--success: #7fd992;
}
html,
body,
#__nuxt {
min-height: 100%;
background: var(--bg);
color: var(--text);
}
body {
font-family: 'Segoe UI', 'Malgun Gothic', system-ui, sans-serif;
background-image:
radial-gradient(ellipse 80% 50% at 20% -10%, rgba(61, 139, 253, 0.18), transparent),
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(127, 217, 146, 0.08), transparent),
linear-gradient(180deg, #0c1117 0%, #0a0e14 100%);
background-attachment: fixed;
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition;
}
.panel {
@apply rounded-lg border backdrop-blur;
border-color: var(--border);
background-color: color-mix(in srgb, var(--bg-panel) 90%, transparent);
}
.btn-primary {
@apply btn text-white hover:brightness-110 disabled:opacity-50;
background-color: var(--accent);
}
.btn-ghost {
@apply btn bg-transparent hover:brightness-125;
border: 1px solid var(--border);
color: var(--text);
}
.btn-danger {
@apply btn text-white hover:brightness-110;
background-color: color-mix(in srgb, var(--danger) 90%, transparent);
}
.input {
@apply w-full rounded-md px-3 py-2 text-sm outline-none;
border: 1px solid var(--border);
background-color: var(--bg-elevated);
color: var(--text);
}
.input:focus {
box-shadow: 0 0 0 2px var(--accent);
}
.input::placeholder {
color: var(--text-muted);
}
}
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { RoomAdmin } from '~/types/room'
defineProps<{
rooms: RoomAdmin[]
loading?: boolean
}>()
const emit = defineEmits<{
delete: [id: string]
copy: [id: string]
refresh: []
}>()
</script>
<template>
<div class="panel overflow-hidden">
<div class="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
<h2 class="font-semibold">방 목록</h2>
<button type="button" class="btn-ghost !py-1 text-xs" @click="emit('refresh')">새로고침</button>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[640px] text-left text-sm">
<thead class="bg-[var(--bg-elevated)] text-[var(--text-muted)]">
<tr>
<th class="px-4 py-2 font-medium">이름</th>
<th class="px-4 py-2 font-medium">ID</th>
<th class="px-4 py-2 font-medium">인원</th>
<th class="px-4 py-2 font-medium">방송</th>
<th class="px-4 py-2 font-medium">생성</th>
<th class="px-4 py-2 font-medium">동작</th>
</tr>
</thead>
<tbody>
<tr v-for="room in rooms" :key="room.id" class="border-t border-[var(--border)]">
<td class="px-4 py-3">{{ room.name }}</td>
<td class="px-4 py-3 font-mono text-xs text-[var(--text-muted)]">{{ room.id }}</td>
<td class="px-4 py-3">{{ room.memberCount ?? 0 }}</td>
<td class="px-4 py-3">{{ room.streamCount ?? 0 }}</td>
<td class="px-4 py-3 text-[var(--text-muted)]">
{{ room.createdAt ? new Date(room.createdAt).toLocaleString() : '-' }}
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-2">
<button type="button" class="btn-ghost !py-1 text-xs" @click="emit('copy', room.id)">
URL 복사
</button>
<NuxtLink :to="`/room/${room.id}`" class="btn-ghost !py-1 text-xs" target="_blank">
열기
</NuxtLink>
<button type="button" class="btn-danger !py-1 text-xs" @click="emit('delete', room.id)">
삭제
</button>
</div>
</td>
</tr>
<tr v-if="!rooms.length && !loading">
<td colspan="6" class="px-4 py-8 text-center text-[var(--text-muted)]">
생성된 방이 없습니다.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
const props = defineProps<{
roomName: string
needPassword: boolean
loading?: boolean
error?: string | null
}>()
const emit = defineEmits<{
submit: [payload: { nickname: string; password?: string }]
}>()
const { nickname } = useNickname()
const password = ref('')
const localNick = ref(nickname.value || '')
function onSubmit() {
if (!localNick.value.trim()) return
nickname.value = localNick.value.trim()
emit('submit', {
nickname: localNick.value.trim(),
password: props.needPassword ? password.value : undefined,
})
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<form class="panel w-full max-w-md space-y-4 p-6 shadow-xl" @submit.prevent="onSubmit">
<div>
<p class="text-xs uppercase tracking-widest text-[var(--text-muted)]">방 입장</p>
<h2 class="mt-1 text-xl font-semibold">{{ roomName || 'Room' }}</h2>
</div>
<label class="block space-y-1.5">
<span class="text-sm text-[var(--text-muted)]">닉네임</span>
<input v-model="localNick" class="input" maxlength="32" required placeholder="표시할 이름" />
</label>
<label v-if="needPassword" class="block space-y-1.5">
<span class="text-sm text-[var(--text-muted)]">방 비밀번호</span>
<input
v-model="password"
class="input"
type="password"
required
placeholder="관리자가 설정한 비밀번호"
autocomplete="current-password"
/>
</label>
<p v-if="error" class="text-sm text-[var(--danger)]">{{ error }}</p>
<button type="submit" class="btn-primary w-full" :disabled="loading">
{{ loading ? '입장 중…' : '입장' }}
</button>
</form>
</div>
</template>
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import type { ActiveStream, Participant, WatchMap } from '~/types/room'
const props = defineProps<{
participants: Participant[]
activeStreams: ActiveStream[]
watchMap: WatchMap
myClientId: string
watchingStreamIds: string[]
}>()
const emit = defineEmits<{
watch: [stream: ActiveStream]
unwatch: [stream: ActiveStream]
}>()
function nicknameOf(clientId: string) {
return props.participants.find((p) => p.clientId === clientId)?.nickname || clientId.slice(0, 6)
}
function viewersFor(streamId: string) {
const ids: string[] = []
for (const [viewerId, streams] of Object.entries(props.watchMap)) {
if (streams.includes(streamId)) ids.push(viewerId)
}
return ids.map(nicknameOf)
}
function isWatching(streamId: string) {
return props.watchingStreamIds.includes(streamId)
}
function watchingSummary(clientId: string) {
const streams = props.watchMap[clientId] || []
if (!streams.length) return '시청 없음'
return streams
.map((sid) => {
const s = props.activeStreams.find((a) => a.streamId === sid)
return s?.broadcasterNickname || 'stream'
})
.join(', ')
}
</script>
<template>
<aside class="flex h-full flex-col gap-4 border-r border-[var(--border)] bg-[var(--bg-elevated)]/50 p-3">
<section>
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
참여자 ({{ participants.length }})
</h3>
<ul class="space-y-2">
<li
v-for="p in participants"
:key="p.clientId"
class="rounded-md border border-[var(--border)] bg-[var(--bg-panel)] px-2.5 py-2 text-sm"
>
<div class="flex items-center justify-between gap-2">
<span class="font-medium">
{{ p.nickname }}
<span v-if="p.clientId === myClientId" class="text-[var(--text-muted)]">(나)</span>
</span>
<span
v-if="p.isStreaming"
class="rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] text-[var(--accent)]"
>
LIVE
</span>
</div>
<p class="mt-1 text-[11px] text-[var(--text-muted)]">
시청: {{ watchingSummary(p.clientId) }}
</p>
</li>
</ul>
</section>
<section class="flex-1 overflow-auto">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
방송 중 ({{ activeStreams.length }})
</h3>
<ul class="space-y-2">
<li
v-for="s in activeStreams"
:key="s.streamId"
class="rounded-md border border-[var(--border)] bg-[var(--bg-panel)] px-2.5 py-2 text-sm"
>
<p class="font-medium">{{ s.broadcasterNickname }}</p>
<p class="mt-0.5 text-[11px] text-[var(--text-muted)]">
시청자: {{ viewersFor(s.streamId).join(', ') || '없음' }}
</p>
<div class="mt-2">
<button
v-if="s.broadcasterId !== myClientId"
type="button"
class="btn-ghost w-full !py-1 text-xs"
@click="isWatching(s.streamId) ? emit('unwatch', s) : emit('watch', s)"
>
{{ isWatching(s.streamId) ? '시청 종료' : '시청' }}
</button>
<p v-else class="text-[11px] text-[var(--text-muted)]">내 화면</p>
</div>
</li>
<li v-if="!activeStreams.length" class="text-xs text-[var(--text-muted)]">
아직 공유 중인 화면이 없습니다.
</li>
</ul>
</section>
</aside>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
const props = defineProps<{
roomName: string
nickname: string
joinedAt: number | null
isSharing: boolean
memberCount: number
maxMembers: number
layoutMode: 'grid' | 'focus'
}>()
const emit = defineEmits<{
toggleShare: []
toggleLayout: []
leave: []
}>()
const { formatted } = useRoomTimer(() => props.joinedAt)
</script>
<template>
<header class="flex flex-wrap items-center gap-3 border-b border-[var(--border)] bg-[var(--bg-elevated)]/80 px-4 py-3 backdrop-blur">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold">{{ roomName }}</p>
<p class="text-xs text-[var(--text-muted)]">
{{ nickname }} · 입장 {{ formatted }} · {{ memberCount }}/{{ maxMembers }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="btn-ghost" type="button" @click="emit('toggleLayout')">
{{ layoutMode === 'grid' ? '포커스' : '그리드' }}
</button>
<button
type="button"
:class="isSharing ? 'btn-danger' : 'btn-primary'"
@click="emit('toggleShare')"
>
{{ isSharing ? '공유 중지' : '화면 공유' }}
</button>
<button class="btn-ghost" type="button" @click="emit('leave')">나가기</button>
</div>
</header>
</template>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { ActiveStream } from '~/types/room'
const props = defineProps<{
streams: ActiveStream[]
remoteStreams: Record<string, MediaStream>
localPreview?: { streamId: string; stream: MediaStream; title: string; startedAt: number } | null
layoutMode: 'grid' | 'focus'
focusStreamId: string | null
volumeMap: Record<string, number>
mutedMap: Record<string, boolean>
viewerNamesByStream: Record<string, string[]>
}>()
const emit = defineEmits<{
'update:volume': [streamId: string, value: number]
'update:muted': [streamId: string, value: boolean]
stop: [streamId: string]
focus: [streamId: string]
}>()
const cards = computed(() => {
const list: Array<{
streamId: string
title: string
mediaStream: MediaStream | null
startedAt: number
isLocal?: boolean
}> = []
if (props.localPreview) {
list.push({
streamId: props.localPreview.streamId,
title: props.localPreview.title,
mediaStream: props.localPreview.stream,
startedAt: props.localPreview.startedAt,
isLocal: true,
})
}
for (const s of props.streams) {
if (props.localPreview?.streamId === s.streamId) continue
list.push({
streamId: s.streamId,
title: s.broadcasterNickname,
mediaStream: props.remoteStreams[s.streamId] || null,
startedAt: s.startedAt,
})
}
if (props.layoutMode === 'focus' && props.focusStreamId) {
return [...list].sort((a, b) => {
if (a.streamId === props.focusStreamId) return -1
if (b.streamId === props.focusStreamId) return 1
return 0
})
}
return list
})
const gridClass = computed(() => {
if (props.layoutMode === 'focus') {
return 'grid grid-cols-1 gap-3 lg:grid-cols-4'
}
const n = cards.value.length
if (n <= 1) return 'grid grid-cols-1 gap-3'
if (n === 2) return 'grid grid-cols-1 gap-3 md:grid-cols-2'
if (n <= 4) return 'grid grid-cols-1 gap-3 md:grid-cols-2'
return 'grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3'
})
</script>
<template>
<div :class="gridClass">
<RoomVideoPlayerCard
v-for="(card, index) in cards"
:key="card.streamId"
:class="layoutMode === 'focus' && index === 0 ? 'lg:col-span-3 lg:row-span-2' : ''"
:stream-id="card.streamId"
:title="card.isLocal ? `${card.title} (미리보기)` : card.title"
:media-stream="card.mediaStream"
:started-at="card.startedAt"
:viewer-names="viewerNamesByStream[card.streamId] || []"
:volume="volumeMap[card.streamId] ?? 1"
:muted="mutedMap[card.streamId] ?? false"
:focused="focusStreamId === card.streamId"
@update:volume="emit('update:volume', card.streamId, $event)"
@update:muted="emit('update:muted', card.streamId, $event)"
@stop="emit('stop', card.streamId)"
@focus="emit('focus', card.streamId)"
/>
<div
v-if="!cards.length"
class="flex min-h-[240px] items-center justify-center rounded-lg border border-dashed border-[var(--border)] text-sm text-[var(--text-muted)]"
>
시청할 화면을 왼쪽에서 선택하세요.
</div>
</div>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
const props = defineProps<{
streamId: string
title: string
mediaStream: MediaStream | null
startedAt: number
viewerNames: string[]
volume: number
muted: boolean
focused?: boolean
}>()
const emit = defineEmits<{
'update:volume': [value: number]
'update:muted': [value: boolean]
stop: []
focus: []
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
const { formatted } = useRoomTimer(() => props.startedAt)
watch(
() => props.mediaStream,
(stream) => {
const el = videoRef.value
if (!el) return
el.srcObject = stream
el.play().catch(() => {})
},
{ immediate: true },
)
watch(
() => [props.volume, props.muted] as const,
([volume, muted]) => {
const el = videoRef.value
if (!el) return
el.volume = volume
el.muted = muted
},
{ immediate: true },
)
onMounted(() => {
const el = videoRef.value
if (el && props.mediaStream) {
el.srcObject = props.mediaStream
el.volume = props.volume
el.muted = props.muted
el.play().catch(() => {})
}
})
</script>
<template>
<article
class="flex flex-col overflow-hidden rounded-lg border border-[var(--border)] bg-black/40"
:class="focused ? 'ring-2 ring-[var(--accent)]' : ''"
>
<div class="relative aspect-video bg-black" @dblclick="emit('focus')">
<video ref="videoRef" class="h-full w-full object-contain" autoplay playsinline />
<div class="pointer-events-none absolute left-2 top-2 rounded bg-black/60 px-2 py-1 text-xs">
{{ title }} · {{ formatted }}
</div>
<div
v-if="viewerNames.length"
class="pointer-events-none absolute bottom-2 left-2 max-w-[80%] truncate rounded bg-black/60 px-2 py-1 text-[11px] text-[var(--text-muted)]"
>
시청: {{ viewerNames.join(', ') }}
</div>
</div>
<div class="flex items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 py-2">
<button
type="button"
class="btn-ghost !px-2 !py-1 text-xs"
@click="emit('update:muted', !muted)"
>
{{ muted ? '음소거됨' : '소리' }}
</button>
<input
class="flex-1 accent-[var(--accent)]"
type="range"
min="0"
max="1"
step="0.01"
:value="volume"
:disabled="muted"
@input="emit('update:volume', Number(($event.target as HTMLInputElement).value))"
/>
<span class="w-10 text-right text-xs text-[var(--text-muted)]">{{ Math.round(volume * 100) }}%</span>
<button type="button" class="btn-ghost !px-2 !py-1 text-xs" @click="emit('stop')">종료</button>
</div>
</article>
</template>
+21
View File
@@ -0,0 +1,21 @@
let audioCtx: AudioContext | null = null
export function useAudioUnlock() {
const unlocked = useState('audio-unlocked', () => false)
async function unlockAudio() {
if (!import.meta.client) return
try {
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
if (!audioCtx) audioCtx = new Ctx()
if (audioCtx.state === 'suspended') {
await audioCtx.resume()
}
unlocked.value = true
} catch {
// ignore
}
}
return { unlocked, unlockAudio }
}
+14
View File
@@ -0,0 +1,14 @@
import { useLocalStorage } from '@vueuse/core'
export function useClientId() {
const clientId = useLocalStorage('sejong.clientId', '')
if (import.meta.client && !clientId.value) {
clientId.value =
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `client-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
return { clientId }
}
+6
View File
@@ -0,0 +1,6 @@
import { useLocalStorage } from '@vueuse/core'
export function useNickname() {
const nickname = useLocalStorage('sejong.nickname', '')
return { nickname }
}
+33
View File
@@ -0,0 +1,33 @@
import { useLocalStorage } from '@vueuse/core'
export function useRoomAccess(roomId: MaybeRefOrGetter<string>) {
const storageKey = computed(() => `sejong.roomAccess.${toValue(roomId)}`)
const accessToken = useLocalStorage(storageKey, '')
async function requestAccess(options: {
clientId: string
password?: string
accessToken?: string
}) {
const id = toValue(roomId)
const res = await $fetch<{ accessToken: string; room: { id: string; name: string } }>(
`/api/rooms/${id}/access`,
{
method: 'POST',
body: {
clientId: options.clientId,
password: options.password,
accessToken: options.accessToken || accessToken.value || undefined,
},
},
)
accessToken.value = res.accessToken
return res
}
function clearAccess() {
accessToken.value = ''
}
return { accessToken, requestAccess, clearAccess }
}
+26
View File
@@ -0,0 +1,26 @@
import { useTimestamp } from '@vueuse/core'
export function useRoomTimer(startedAt: MaybeRefOrGetter<number | null>) {
const now = useTimestamp({ interval: 1000 })
const elapsedMs = computed(() => {
const start = toValue(startedAt)
if (!start) return 0
return Math.max(0, now.value - start)
})
const formatted = computed(() => formatDuration(elapsedMs.value))
return { elapsedMs, formatted }
}
export function formatDuration(ms: number) {
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
+52
View File
@@ -0,0 +1,52 @@
export function useScreenShare() {
const localStream = ref<MediaStream | null>(null)
const streamId = ref<string | null>(null)
const error = ref<string | null>(null)
const isSharing = computed(() => Boolean(localStream.value))
async function startShare(clientId: string) {
error.value = null
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60, max: 60 },
// @ts-expect-error displaySurface is supported in Chromium
displaySurface: 'window',
},
audio: {
// @ts-expect-error Chromium system audio options
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
})
localStream.value = stream
streamId.value = `${clientId}-${Date.now()}`
const videoTrack = stream.getVideoTracks()[0]
videoTrack?.addEventListener('ended', () => {
stopShare()
})
return { stream, streamId: streamId.value }
} catch (e) {
error.value = e instanceof Error ? e.message : 'Screen share failed'
throw e
}
}
function stopShare() {
localStream.value?.getTracks().forEach((t) => t.stop())
localStream.value = null
streamId.value = null
}
onBeforeUnmount(() => {
stopShare()
})
return { localStream, streamId, isSharing, error, startShare, stopShare }
}
+33
View File
@@ -0,0 +1,33 @@
import { io, type Socket } from 'socket.io-client'
import type { ClientToServerEvents, ServerToClientEvents } from '~/types/signaling'
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>
let socketSingleton: AppSocket | null = null
export function useSocket() {
const socket = useState<AppSocket | null>('app-socket', () => null)
function connect() {
if (!import.meta.client) return null
if (socketSingleton?.connected) {
socket.value = socketSingleton
return socketSingleton
}
socketSingleton = io({
path: '/socket.io/',
transports: ['websocket', 'polling'],
autoConnect: true,
})
socket.value = socketSingleton
return socketSingleton
}
function disconnect() {
socketSingleton?.disconnect()
socketSingleton = null
socket.value = null
}
return { socket, connect, disconnect }
}
+163
View File
@@ -0,0 +1,163 @@
import type { AppSocket } from './useSocket'
type PeerKey = string
function peerKey(remoteId: string, streamId: string) {
return `${remoteId}:${streamId}`
}
export function useWebRTC(options: {
socket: Ref<AppSocket | null>
clientId: Ref<string>
getLocalStream: () => MediaStream | null
}) {
const peers = new Map<PeerKey, RTCPeerConnection>()
const remoteStreams = ref<Record<string, MediaStream>>({})
const iceServers: RTCIceServer[] = [{ urls: 'stun:stun.l.google.com:19302' }]
function getPc(remoteId: string, streamId: string) {
const key = peerKey(remoteId, streamId)
let pc = peers.get(key)
if (pc) return pc
pc = new RTCPeerConnection({ iceServers })
peers.set(key, pc)
pc.onicecandidate = (ev) => {
if (!ev.candidate || !options.socket.value) return
options.socket.value.emit('webrtc:ice', {
to: remoteId,
streamId,
candidate: ev.candidate.toJSON(),
})
}
pc.ontrack = (ev) => {
const stream = ev.streams[0] || new MediaStream([ev.track])
remoteStreams.value = { ...remoteStreams.value, [streamId]: stream }
}
pc.onconnectionstatechange = () => {
if (pc?.connectionState === 'failed' || pc?.connectionState === 'closed') {
closePeer(remoteId, streamId)
}
}
return pc
}
function closePeer(remoteId: string, streamId: string) {
const key = peerKey(remoteId, streamId)
const pc = peers.get(key)
if (pc) {
pc.close()
peers.delete(key)
}
if (remoteStreams.value[streamId]) {
const next = { ...remoteStreams.value }
delete next[streamId]
remoteStreams.value = next
}
}
function closeAll() {
for (const pc of peers.values()) pc.close()
peers.clear()
remoteStreams.value = {}
}
async function createOfferAsViewer(broadcasterId: string, streamId: string) {
const pc = getPc(broadcasterId, streamId)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
options.socket.value?.emit('webrtc:offer', {
to: broadcasterId,
streamId,
sdp: offer,
})
}
async function handleOffer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
const pc = getPc(from, streamId)
const local = options.getLocalStream()
if (local) {
for (const track of local.getTracks()) {
const exists = pc.getSenders().some((s) => s.track?.id === track.id)
if (!exists) pc.addTrack(track, local)
}
}
await pc.setRemoteDescription(sdp)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
options.socket.value?.emit('webrtc:answer', {
to: from,
streamId,
sdp: answer,
})
}
async function handleAnswer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
const pc = peers.get(peerKey(from, streamId))
if (!pc) return
await pc.setRemoteDescription(sdp)
}
async function handleIce(from: string, streamId: string, candidate: RTCIceCandidateInit) {
const pc = peers.get(peerKey(from, streamId))
if (!pc) return
try {
await pc.addIceCandidate(candidate)
} catch {
// ignore late candidates
}
}
/** Broadcaster: when a viewer starts watching, wait for their offer (viewer-initiated). */
function attachLocalTracksToPeer(remoteId: string, streamId: string) {
const local = options.getLocalStream()
if (!local) return
const pc = getPc(remoteId, streamId)
for (const track of local.getTracks()) {
const exists = pc.getSenders().some((s) => s.track?.id === track.id)
if (!exists) pc.addTrack(track, local)
}
}
function bindSocketHandlers() {
const s = options.socket.value
if (!s) return
s.on('webrtc:offer', async (payload) => {
if (!payload.from || !payload.sdp || !payload.streamId) return
await handleOffer(payload.from, payload.streamId, payload.sdp)
})
s.on('webrtc:answer', async (payload) => {
if (!payload.from || !payload.sdp || !payload.streamId) return
await handleAnswer(payload.from, payload.streamId, payload.sdp)
})
s.on('webrtc:ice', async (payload) => {
if (!payload.from || !payload.candidate || !payload.streamId) return
await handleIce(payload.from, payload.streamId, payload.candidate)
})
s.on('peer:left', (payload) => {
for (const key of [...peers.keys()]) {
if (key.startsWith(`${payload.clientId}:`)) {
const streamId = key.split(':')[1]
closePeer(payload.clientId, streamId)
}
}
})
}
return {
remoteStreams,
createOfferAsViewer,
attachLocalTracksToPeer,
closePeer,
closeAll,
bindSocketHandlers,
}
}
View File
Whitespace-only changes.
+29
View File
@@ -0,0 +1,29 @@
services:
app:
build: .
container_name: sejong-tv
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: file:/app/data/sejong.db
HOST: 0.0.0.0
PORT: 3000
NITRO_HOST: 0.0.0.0
NITRO_PORT: 3000
volumes:
- sqlite_data:/app/data
ports:
- "3000:3000"
# NPM Plus와 같은 Docker 네트워크에 붙이려면:
# 1) ports 섹션을 주석 처리하고
# 2) 아래 networks를 활성화한 뒤
# 3) external 네트워크 이름을 실제 NPM Plus 네트워크로 맞추세요.
# networks:
# - npmplus
volumes:
sqlite_data:
# networks:
# npmplus:
# external: true
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -e
npx prisma migrate deploy
exec node .output/server/index.mjs
+47
View File
@@ -0,0 +1,47 @@
# NPM Plus 연동
Sejong TV Compose에는 Nginx가 없습니다. HTTPS/WSS는 기존 **NPM Plus**에서 처리합니다.
## 1. Docker 네트워크
`docker-compose.yml`의 `npmplus` external 네트워크 이름을, NPM Plus 컨테이너가 붙어 있는 네트워크와 동일하게 맞춥니다.
```bash
docker network ls
# 예: npmplus, proxy, nginx-proxy-manager_default 등
```
## 2. Proxy Host (권장: 앱을 NPM Plus 네트워크에 연결)
`docker-compose.yml`에서 `ports`를 끄고 `networks.npmplus`를 켠 뒤:
```bash
docker network ls
docker compose up -d --build
```
| 항목 | 값 |
|------|-----|
| Domain Names | `tv.example.com` (실제 도메인) |
| Scheme | `http` |
| Forward Hostname / IP | `sejong-tv` (container_name) |
| Forward Port | `3000` |
| Websockets Support | **ON** (필수) |
| SSL | Let's Encrypt 등 활성화 |
로컬만 쓸 때는 `http://localhost:3000` 으로 직접 접속하면 됩니다.
## 3. 권장 Custom Nginx 설정
장시간 방 유지 시 타임아웃을 늘립니다.
```nginx
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
```
## 4. 확인
- `https://tv.example.com` 접속
- 브라우저 개발자 도구 Network에서 `/socket.io/` 가 `101 Switching Protocols` 인지 확인
- Windows Chrome/Edge에서 화면 공유(시스템 오디오 포함) 테스트
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="min-h-screen">
<slot />
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxtjs/tailwindcss', '@vueuse/nuxt', '@pinia/nuxt'],
css: ['~/assets/css/main.css'],
runtimeConfig: {
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
adminUsername: process.env.ADMIN_USERNAME || 'admin',
adminPassword: process.env.ADMIN_PASSWORD || 'admin',
maxRoomMembers: Number(process.env.MAX_ROOM_MEMBERS || 8),
maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS || 4),
roomAccessTtlDays: Number(process.env.ROOM_ACCESS_TTL_DAYS || 7),
public: {
maxRoomMembers: Number(process.env.MAX_ROOM_MEMBERS || 8),
maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS || 4),
},
},
nitro: {
experimental: {
websocket: true,
},
},
app: {
head: {
title: 'Sejong TV',
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: '멀티 스크린 화면 공유 플랫폼' },
],
},
},
})
+13090
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "sejong-tv",
"type": "module",
"private": true,
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"postinstall": "prisma generate && nuxt prepare",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:push": "prisma db push"
},
"dependencies": {
"@prisma/client": "^6.9.0",
"@vueuse/core": "^13.3.0",
"@vueuse/nuxt": "^13.3.0",
"bcryptjs": "^3.0.2",
"jose": "^6.0.11",
"nanoid": "^5.1.5",
"nuxt": "^3.17.5",
"pinia": "^3.0.3",
"@pinia/nuxt": "^0.11.1",
"prisma": "^6.9.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"engine.io": "^6.6.4",
"vue": "^3.5.16",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"@types/bcryptjs": "^2.4.6",
"typescript": "^5.8.3"
}
}
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { RoomAdmin } from '~/types/room'
const rooms = ref<RoomAdmin[]>([])
const name = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
const creating = ref(false)
const authed = ref(false)
async function ensureAuth() {
try {
await $fetch('/api/auth/me')
authed.value = true
} catch {
await navigateTo('/admin/login')
}
}
async function loadRooms() {
loading.value = true
try {
rooms.value = await $fetch<RoomAdmin[]>('/api/rooms')
} catch (e) {
error.value = '방 목록을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function createRoom() {
error.value = null
creating.value = true
try {
await $fetch('/api/rooms', {
method: 'POST',
body: { name: name.value, password: password.value },
})
name.value = ''
password.value = ''
await loadRooms()
} catch (e: unknown) {
error.value = '방 생성에 실패했습니다.'
} finally {
creating.value = false
}
}
async function deleteRoom(id: string) {
if (!confirm('이 방을 삭제하면 접속 중인 사용자가 모두 퇴장합니다. 계속할까요?')) return
await $fetch(`/api/rooms/${id}`, { method: 'DELETE' })
await loadRooms()
}
async function copyUrl(id: string) {
const url = `${window.location.origin}/room/${id}`
await navigator.clipboard.writeText(url)
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/admin/login')
}
onMounted(async () => {
await ensureAuth()
if (authed.value) await loadRooms()
})
</script>
<template>
<main v-if="authed" class="mx-auto max-w-5xl space-y-6 px-4 py-8">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-xs uppercase tracking-widest text-[var(--accent)]">Sejong TV</p>
<h1 class="text-2xl font-semibold">관리자</h1>
</div>
<div class="flex gap-2">
<NuxtLink to="/" class="btn-ghost">홈</NuxtLink>
<button type="button" class="btn-ghost" @click="logout">로그아웃</button>
</div>
</div>
<form class="panel grid gap-3 p-4 md:grid-cols-[1fr_1fr_auto]" @submit.prevent="createRoom">
<input v-model="name" class="input" placeholder="방 이름" required />
<input v-model="password" class="input" type="password" placeholder="방 비밀번호 (4자 이상)" required minlength="4" />
<button type="submit" class="btn-primary" :disabled="creating">방 생성</button>
<p v-if="error" class="md:col-span-3 text-sm text-[var(--danger)]">{{ error }}</p>
</form>
<AdminRoomTable
:rooms="rooms"
:loading="loading"
@delete="deleteRoom"
@copy="copyUrl"
@refresh="loadRooms"
/>
</main>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
definePageMeta({ layout: 'default' })
const username = ref('admin')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
async function login() {
error.value = null
loading.value = true
try {
await $fetch('/api/auth/login', {
method: 'POST',
body: { username: username.value, password: password.value },
})
await navigateTo('/admin')
} catch (e: unknown) {
error.value = '로그인에 실패했습니다.'
} finally {
loading.value = false
}
}
onMounted(async () => {
try {
await $fetch('/api/auth/me')
await navigateTo('/admin')
} catch {
// not logged in
}
})
</script>
<template>
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6">
<form class="panel space-y-4 p-6" @submit.prevent="login">
<div>
<p class="text-xs uppercase tracking-widest text-[var(--text-muted)]">Admin</p>
<h1 class="mt-1 text-2xl font-semibold">관리자 로그인</h1>
</div>
<label class="block space-y-1.5">
<span class="text-sm text-[var(--text-muted)]">아이디</span>
<input v-model="username" class="input" required />
</label>
<label class="block space-y-1.5">
<span class="text-sm text-[var(--text-muted)]">비밀번호</span>
<input v-model="password" class="input" type="password" required />
</label>
<p v-if="error" class="text-sm text-[var(--danger)]">{{ error }}</p>
<button type="submit" class="btn-primary w-full" :disabled="loading">
{{ loading ? '로그인 중…' : '로그인' }}
</button>
<NuxtLink to="/" class="block text-center text-sm text-[var(--text-muted)] hover:underline">
홈으로
</NuxtLink>
</form>
</main>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
const roomCode = ref('')
function goRoom() {
const id = roomCode.value.trim()
if (!id) return
navigateTo(`/room/${id}`)
}
</script>
<template>
<main class="mx-auto flex min-h-screen max-w-3xl flex-col justify-center px-6 py-16">
<p class="text-sm uppercase tracking-[0.2em] text-[var(--accent)]">Sejong TV</p>
<h1 class="mt-3 text-4xl font-semibold tracking-tight md:text-5xl">
멀티 스크린<br />화면 공유
</h1>
<p class="mt-4 max-w-xl text-[var(--text-muted)]">
관리자가 만든 방 URL로 입장해 여러 화면을 동시에 시청하고, 스트림별로 볼륨을 조절하세요.
</p>
<form class="mt-10 flex flex-col gap-3 sm:flex-row" @submit.prevent="goRoom">
<input
v-model="roomCode"
class="input sm:flex-1"
placeholder="방 ID 입력"
autocomplete="off"
/>
<button type="submit" class="btn-primary">방 입장</button>
</form>
<p class="mt-8 text-sm text-[var(--text-muted)]">
관리자이신가요?
<NuxtLink to="/admin" class="text-[var(--accent)] hover:underline">관리자 페이지</NuxtLink>
</p>
</main>
</template>
+351
View File
@@ -0,0 +1,351 @@
<script setup lang="ts">
import type { ActiveStream, RoomState } from '~/types/room'
const route = useRoute()
const config = useRuntimeConfig()
const roomId = computed(() => String(route.params.roomId || ''))
const store = useRoomStore()
const { clientId } = useClientId()
const { nickname } = useNickname()
const { accessToken, requestAccess, clearAccess } = useRoomAccess(roomId)
const { socket, connect, disconnect } = useSocket()
const { unlockAudio } = useAudioUnlock()
const {
localStream,
streamId: localStreamId,
isSharing,
startShare,
stopShare,
} = useScreenShare()
const roomMeta = ref<{ id: string; name: string } | null>(null)
const showJoin = ref(true)
const needPassword = ref(true)
const joinLoading = ref(false)
const joinError = ref<string | null>(null)
const shareError = ref<string | null>(null)
const localStartedAt = ref<number | null>(null)
const handlersBound = ref(false)
const {
remoteStreams,
createOfferAsViewer,
closePeer,
closeAll,
bindSocketHandlers,
} = useWebRTC({
socket,
clientId,
getLocalStream: () => localStream.value,
})
const maxMembers = computed(() => config.public.maxRoomMembers || 8)
const watchingStreams = computed(() =>
store.activeStreams.filter((s) => store.watchingStreamIds.includes(s.streamId)),
)
const viewerNamesByStream = computed(() => {
const out: Record<string, string[]> = {}
for (const [streamId, viewerIds] of Object.entries(store.viewersOfStream)) {
out[streamId] = viewerIds.map(
(id) => store.participants.find((p) => p.clientId === id)?.nickname || id.slice(0, 6),
)
}
return out
})
const localPreview = computed(() => {
if (!localStream.value || !localStreamId.value) return null
return {
streamId: localStreamId.value,
stream: localStream.value,
title: nickname.value || '나',
startedAt: localStartedAt.value || Date.now(),
}
})
async function loadMeta() {
try {
roomMeta.value = await $fetch(`/api/rooms/${roomId.value}`)
store.roomName = roomMeta.value.name
} catch {
joinError.value = '존재하지 않는 방입니다.'
}
}
async function tryTokenJoin() {
if (!accessToken.value) {
needPassword.value = true
return
}
try {
const res = await requestAccess({
clientId: clientId.value,
accessToken: accessToken.value,
})
roomMeta.value = res.room
store.roomName = res.room.name
needPassword.value = false
// still show dialog for nickname confirm, or auto-join
if (nickname.value) {
await enterRoom(nickname.value)
}
} catch {
clearAccess()
needPassword.value = true
}
}
function bindSocketOnce() {
if (handlersBound.value || !socket.value) return
handlersBound.value = true
bindSocketHandlers()
socket.value.on('room:state', (state: RoomState) => {
store.applyState(state)
})
socket.value.on('room:force-close', ({ reason }) => {
store.error = reason
leaveRoom(false)
alert(reason)
navigateTo('/')
})
socket.value.on('room:error', ({ error }) => {
store.error = error
})
}
async function enterRoom(nick: string, password?: string) {
joinLoading.value = true
joinError.value = null
try {
if (needPassword.value || password) {
await requestAccess({
clientId: clientId.value,
password,
accessToken: accessToken.value || undefined,
})
} else if (accessToken.value) {
await requestAccess({
clientId: clientId.value,
accessToken: accessToken.value,
})
}
connect()
bindSocketOnce()
await new Promise<void>((resolve, reject) => {
const s = socket.value
if (!s) {
reject(new Error('Socket not connected'))
return
}
const doJoin = () => {
s.emit(
'room:join',
{
roomId: roomId.value,
clientId: clientId.value,
nickname: nick,
accessToken: accessToken.value,
},
(res) => {
if (!res?.ok) {
reject(new Error(res?.error || 'Join failed'))
return
}
resolve()
},
)
}
if (s.connected) doJoin()
else s.once('connect', doJoin)
})
nickname.value = nick
store.joined = true
store.joinedAt = Date.now()
showJoin.value = false
needPassword.value = false
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '입장 실패'
joinError.value = msg
if (msg.includes('Password') || msg.includes('password') || msg.includes('Invalid')) {
needPassword.value = true
clearAccess()
}
} finally {
joinLoading.value = false
}
}
async function onJoinSubmit(payload: { nickname: string; password?: string }) {
needPassword.value = Boolean(payload.password) || needPassword.value
await enterRoom(payload.nickname, payload.password)
}
async function toggleShare() {
shareError.value = null
if (isSharing.value) {
const sid = localStreamId.value
stopShare()
if (sid) socket.value?.emit('stream:stop', { streamId: sid })
localStartedAt.value = null
return
}
try {
await unlockAudio()
const { streamId } = await startShare(clientId.value)
await new Promise<void>((resolve, reject) => {
socket.value?.emit('stream:start', { streamId }, (res) => {
if (!res?.ok) reject(new Error(res?.error || 'Stream start failed'))
else resolve()
})
})
localStartedAt.value = Date.now()
store.ensureAudioDefaults(streamId)
store.mutedMap = { ...store.mutedMap, [streamId]: true }
} catch (e: unknown) {
stopShare()
shareError.value = e instanceof Error ? e.message : '화면 공유 실패'
}
}
async function watchStream(stream: ActiveStream) {
await unlockAudio()
store.ensureAudioDefaults(stream.streamId)
store.setWatching(stream.streamId, true)
if (!store.focusStreamId) store.focusStreamId = stream.streamId
socket.value?.emit('watch:start', {
streamId: stream.streamId,
broadcasterId: stream.broadcasterId,
})
await createOfferAsViewer(stream.broadcasterId, stream.streamId)
}
function unwatchStream(stream: ActiveStream) {
store.setWatching(stream.streamId, false)
socket.value?.emit('watch:stop', {
streamId: stream.streamId,
broadcasterId: stream.broadcasterId,
})
closePeer(stream.broadcasterId, stream.streamId)
}
function onStopWatch(streamId: string) {
if (localStreamId.value === streamId) {
toggleShare()
return
}
const stream = store.activeStreams.find((s) => s.streamId === streamId)
if (stream) unwatchStream(stream)
}
function leaveRoom(navigate = true) {
if (isSharing.value) {
const sid = localStreamId.value
stopShare()
if (sid) socket.value?.emit('stream:stop', { streamId: sid })
}
for (const sid of [...store.watchingStreamIds]) {
const stream = store.activeStreams.find((s) => s.streamId === sid)
if (stream) unwatchStream(stream)
}
socket.value?.emit('room:leave')
closeAll()
store.reset()
disconnect()
handlersBound.value = false
showJoin.value = true
if (navigate) navigateTo('/')
}
onMounted(async () => {
await loadMeta()
await tryTokenJoin()
})
onBeforeUnmount(() => {
leaveRoom(false)
})
</script>
<template>
<div class="flex h-screen flex-col">
<RoomJoinDialog
v-if="showJoin"
:room-name="roomMeta?.name || store.roomName || roomId"
:need-password="needPassword"
:loading="joinLoading"
:error="joinError"
@submit="onJoinSubmit"
/>
<template v-if="store.joined">
<RoomHeader
:room-name="store.roomName || roomMeta?.name || roomId"
:nickname="nickname"
:joined-at="store.joinedAt"
:is-sharing="isSharing"
:member-count="store.participants.length"
:max-members="maxMembers"
:layout-mode="store.layoutMode"
@toggle-share="toggleShare"
@toggle-layout="store.layoutMode = store.layoutMode === 'grid' ? 'focus' : 'grid'"
@leave="leaveRoom()"
/>
<p v-if="shareError" class="bg-[var(--danger)]/20 px-4 py-2 text-sm text-[var(--danger)]">
{{ shareError }}
</p>
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[260px_1fr]">
<RoomParticipantList
class="hidden md:flex"
:participants="store.participants"
:active-streams="store.activeStreams"
:watch-map="store.watchMap"
:my-client-id="clientId"
:watching-stream-ids="store.watchingStreamIds"
@watch="watchStream"
@unwatch="unwatchStream"
/>
<main class="min-h-0 overflow-auto p-3">
<div class="mb-3 md:hidden">
<RoomParticipantList
:participants="store.participants"
:active-streams="store.activeStreams"
:watch-map="store.watchMap"
:my-client-id="clientId"
:watching-stream-ids="store.watchingStreamIds"
@watch="watchStream"
@unwatch="unwatchStream"
/>
</div>
<RoomStreamGrid
:streams="watchingStreams"
:remote-streams="remoteStreams"
:local-preview="localPreview"
:layout-mode="store.layoutMode"
:focus-stream-id="store.focusStreamId"
:volume-map="store.volumeMap"
:muted-map="store.mutedMap"
:viewer-names-by-stream="viewerNamesByStream"
@update:volume="(id, v) => (store.volumeMap = { ...store.volumeMap, [id]: v })"
@update:muted="(id, v) => (store.mutedMap = { ...store.mutedMap, [id]: v })"
@stop="onStopWatch"
@focus="store.focusStreamId = $event"
/>
</main>
</div>
</template>
</div>
</template>
@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "Room" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"pwdVersion" INTEGER NOT NULL DEFAULT 1,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdBy" TEXT NOT NULL DEFAULT 'admin'
);
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+17
View File
@@ -0,0 +1,17 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Room {
id String @id
name String
passwordHash String
pwdVersion Int @default(1)
createdAt DateTime @default(now())
createdBy String @default("admin")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+25
View File
@@ -0,0 +1,25 @@
import { ADMIN_COOKIE, signAdminToken } from '../../utils/jwt'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username?: string; password?: string }>(event)
const config = useRuntimeConfig()
if (!body?.username || !body?.password) {
throw createError({ statusCode: 400, statusMessage: 'Username and password required' })
}
if (body.username !== config.adminUsername || body.password !== config.adminPassword) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
const token = await signAdminToken(body.username)
setCookie(event, ADMIN_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return { ok: true, username: body.username }
})
+6
View File
@@ -0,0 +1,6 @@
import { ADMIN_COOKIE } from '../../utils/jwt'
export default defineEventHandler(async (event) => {
deleteCookie(event, ADMIN_COOKIE, { path: '/' })
return { ok: true }
})
+18
View File
@@ -0,0 +1,18 @@
import { ADMIN_COOKIE, verifyAdminToken } from '../../utils/jwt'
export default defineEventHandler(async (event) => {
const token =
getCookie(event, ADMIN_COOKIE) ||
getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
try {
const payload = await verifyAdminToken(token)
return { ok: true, username: payload.username }
} catch {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
})
+19
View File
@@ -0,0 +1,19 @@
import { prisma } from '../../utils/db'
import { forceCloseRoom } from '../../utils/runtimeRooms'
export default defineEventHandler(async (event) => {
const roomId = getRouterParam(event, 'roomId')
if (!roomId) {
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
}
const existing = await prisma.room.findUnique({ where: { id: roomId } })
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
}
forceCloseRoom(roomId, 'Room deleted by admin')
await prisma.room.delete({ where: { id: roomId } })
return { ok: true }
})
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from '../../utils/db'
import { getMemberCount, getStreamCount } from '../../utils/runtimeRooms'
export default defineEventHandler(async (event) => {
const roomId = getRouterParam(event, 'roomId')
if (!roomId) {
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
}
const room = await prisma.room.findUnique({ where: { id: roomId } })
if (!room) {
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
}
return {
id: room.id,
name: room.name,
memberCount: getMemberCount(room.id),
streamCount: getStreamCount(room.id),
}
})
+37
View File
@@ -0,0 +1,37 @@
import bcrypt from 'bcryptjs'
import { prisma } from '../../utils/db'
export default defineEventHandler(async (event) => {
const roomId = getRouterParam(event, 'roomId')
if (!roomId) {
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
}
const body = await readBody<{ name?: string; password?: string }>(event)
const existing = await prisma.room.findUnique({ where: { id: roomId } })
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
}
const data: { name?: string; passwordHash?: string; pwdVersion?: number } = {}
if (body?.name?.trim()) {
data.name = body.name.trim()
}
if (body?.password) {
if (body.password.length < 4) {
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 4 characters' })
}
data.passwordHash = await bcrypt.hash(body.password, 10)
data.pwdVersion = existing.pwdVersion + 1
}
const room = await prisma.room.update({ where: { id: roomId }, data })
return {
id: room.id,
name: room.name,
pwdVersion: room.pwdVersion,
createdAt: room.createdAt.toISOString(),
createdBy: room.createdBy,
}
})
+59
View File
@@ -0,0 +1,59 @@
import bcrypt from 'bcryptjs'
import { prisma } from '../../../utils/db'
import { signRoomAccessToken, verifyRoomAccessToken } from '../../../utils/roomAccess'
export default defineEventHandler(async (event) => {
const roomId = getRouterParam(event, 'roomId')
if (!roomId) {
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
}
const body = await readBody<{
password?: string
clientId?: string
accessToken?: string
}>(event)
if (!body?.clientId) {
throw createError({ statusCode: 400, statusMessage: 'clientId required' })
}
const room = await prisma.room.findUnique({ where: { id: roomId } })
if (!room) {
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
}
const expected = {
roomId: room.id,
clientId: body.clientId,
pwdVersion: room.pwdVersion,
}
if (body.accessToken) {
try {
await verifyRoomAccessToken(body.accessToken, expected)
const accessToken = await signRoomAccessToken(expected)
return {
accessToken,
room: { id: room.id, name: room.name },
}
} catch {
// fall through to password
}
}
if (!body.password) {
throw createError({ statusCode: 401, statusMessage: 'Password required' })
}
const valid = await bcrypt.compare(body.password, room.passwordHash)
if (!valid) {
throw createError({ statusCode: 401, statusMessage: 'Invalid password' })
}
const accessToken = await signRoomAccessToken(expected)
return {
accessToken,
room: { id: room.id, name: room.name },
}
})
+15
View File
@@ -0,0 +1,15 @@
import { prisma } from '../../utils/db'
import { getMemberCount, getStreamCount } from '../../utils/runtimeRooms'
export default defineEventHandler(async () => {
const rooms = await prisma.room.findMany({ orderBy: { createdAt: 'desc' } })
return rooms.map((room) => ({
id: room.id,
name: room.name,
pwdVersion: room.pwdVersion,
createdAt: room.createdAt.toISOString(),
createdBy: room.createdBy,
memberCount: getMemberCount(room.id),
streamCount: getStreamCount(room.id),
}))
})
+38
View File
@@ -0,0 +1,38 @@
import bcrypt from 'bcryptjs'
import { nanoid } from 'nanoid'
import { prisma } from '../../utils/db'
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; password?: string }>(event)
if (!body?.name?.trim() || !body?.password) {
throw createError({ statusCode: 400, statusMessage: 'Name and password required' })
}
if (body.password.length < 4) {
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 4 characters' })
}
const id = nanoid(10)
const passwordHash = await bcrypt.hash(body.password, 10)
const admin = event.context.admin as { username?: string } | undefined
const room = await prisma.room.create({
data: {
id,
name: body.name.trim(),
passwordHash,
pwdVersion: 1,
createdBy: admin?.username || 'admin',
},
})
return {
id: room.id,
name: room.name,
pwdVersion: room.pwdVersion,
createdAt: room.createdAt.toISOString(),
createdBy: room.createdBy,
url: `/room/${room.id}`,
}
})
+37
View File
@@ -0,0 +1,37 @@
import { ADMIN_COOKIE, verifyAdminToken } from '../utils/jwt'
/**
* Protects admin room management endpoints.
* Public: GET /api/rooms/:roomId (meta), POST /api/rooms/:roomId/access
*/
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
const path = url.pathname
const method = event.method
if (!path.startsWith('/api/rooms')) return
const isAccess = /\/api\/rooms\/[^/]+\/access$/.test(path) && method === 'POST'
const isPublicMeta = /\/api\/rooms\/[^/]+$/.test(path) && method === 'GET' && path !== '/api/rooms'
if (isAccess || isPublicMeta) return
const needsAuth =
path === '/api/rooms' ||
(/\/api\/rooms\/[^/]+$/.test(path) && (method === 'PATCH' || method === 'DELETE'))
if (!needsAuth) return
const token =
getCookie(event, ADMIN_COOKIE) ||
getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
try {
event.context.admin = await verifyAdminToken(token)
} catch {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
})
+220
View File
@@ -0,0 +1,220 @@
import type { NitroApp } from 'nitropack'
import { Server as Engine } from 'engine.io'
import { Server } from 'socket.io'
import { defineEventHandler } from 'h3'
import { prisma } from '../utils/db'
import { verifyRoomAccessToken } from '../utils/roomAccess'
import { getMaxConcurrentStreams, getMaxRoomMembers } from '../utils/rooms'
import {
broadcastRoomState,
buildRoomState,
getOrCreateRuntimeRoom,
removeViewerWatches,
setIO,
stopStreamsByClient,
type RoomSession,
} from '../utils/runtimeRooms'
export default defineNitroPlugin((nitroApp: NitroApp) => {
const engine = new Engine()
const io = new Server({
cors: { origin: true, credentials: true },
path: '/socket.io/',
})
io.bind(engine)
setIO(io)
io.on('connection', (socket) => {
let currentRoomId: string | null = null
let currentClientId: string | null = null
const cleanup = () => {
if (!currentRoomId || !currentClientId) return
const room = getOrCreateRuntimeRoom(currentRoomId)
const session = room.sessions.get(currentClientId)
if (session && session.socketId === socket.id) {
stopStreamsByClient(room, currentClientId)
removeViewerWatches(room, currentClientId)
room.sessions.delete(currentClientId)
io.to(currentRoomId).emit('peer:left', { clientId: currentClientId })
broadcastRoomState(currentRoomId)
if (room.sessions.size === 0) {
// keep empty runtime room until deleted from DB; optional cleanup
}
}
currentRoomId = null
currentClientId = null
}
socket.on('room:join', async (payload, ack) => {
try {
const { roomId, clientId, nickname, accessToken } = payload || {}
if (!roomId || !clientId || !nickname?.trim() || !accessToken) {
ack?.({ ok: false, error: 'Invalid join payload' })
return
}
const dbRoom = await prisma.room.findUnique({ where: { id: roomId } })
if (!dbRoom) {
ack?.({ ok: false, error: 'Room not found' })
return
}
await verifyRoomAccessToken(accessToken, {
roomId,
clientId,
pwdVersion: dbRoom.pwdVersion,
})
if (currentRoomId && currentClientId) {
cleanup()
}
const room = getOrCreateRuntimeRoom(roomId)
const existing = room.sessions.get(clientId)
if (existing && existing.socketId !== socket.id) {
const oldSocket = io.sockets.sockets.get(existing.socketId)
oldSocket?.emit('room:force-close', { reason: 'Replaced by new session' })
oldSocket?.disconnect(true)
stopStreamsByClient(room, clientId)
removeViewerWatches(room, clientId)
room.sessions.delete(clientId)
}
const maxMembers = getMaxRoomMembers()
if (!room.sessions.has(clientId) && room.sessions.size >= maxMembers) {
ack?.({ ok: false, error: `Room is full (max ${maxMembers})` })
return
}
const session: RoomSession = {
clientId,
socketId: socket.id,
nickname: nickname.trim().slice(0, 32),
joinedAt: Date.now(),
streamId: null,
}
room.sessions.set(clientId, session)
currentRoomId = roomId
currentClientId = clientId
socket.join(roomId)
ack?.({ ok: true })
socket.emit('room:state', buildRoomState(room))
broadcastRoomState(roomId)
} catch (err) {
const message = err instanceof Error ? err.message : 'Join failed'
ack?.({ ok: false, error: message })
}
})
socket.on('room:leave', () => {
socket.leave(currentRoomId || '')
cleanup()
})
socket.on('stream:start', (payload, ack) => {
if (!currentRoomId || !currentClientId) {
ack?.({ ok: false, error: 'Not in a room' })
return
}
const room = getOrCreateRuntimeRoom(currentRoomId)
const session = room.sessions.get(currentClientId)
if (!session) {
ack?.({ ok: false, error: 'Session missing' })
return
}
if (session.streamId) {
ack?.({ ok: false, error: 'Already streaming' })
return
}
const maxStreams = getMaxConcurrentStreams()
if (room.streams.size >= maxStreams) {
ack?.({ ok: false, error: `Max concurrent streams is ${maxStreams}` })
return
}
const streamId = payload?.streamId || `${currentClientId}-${Date.now()}`
session.streamId = streamId
room.streams.set(streamId, {
streamId,
broadcasterId: currentClientId,
broadcasterNickname: session.nickname,
startedAt: Date.now(),
})
ack?.({ ok: true })
broadcastRoomState(currentRoomId)
})
socket.on('stream:stop', (payload) => {
if (!currentRoomId || !currentClientId) return
const room = getOrCreateRuntimeRoom(currentRoomId)
const session = room.sessions.get(currentClientId)
const streamId = payload?.streamId || session?.streamId
if (!streamId) return
room.streams.delete(streamId)
if (session?.streamId === streamId) session.streamId = null
for (const set of room.watchMap.values()) {
set.delete(streamId)
}
broadcastRoomState(currentRoomId)
})
socket.on('watch:start', (payload) => {
if (!currentRoomId || !currentClientId || !payload?.streamId) return
const room = getOrCreateRuntimeRoom(currentRoomId)
if (!room.streams.has(payload.streamId)) return
let set = room.watchMap.get(currentClientId)
if (!set) {
set = new Set()
room.watchMap.set(currentClientId, set)
}
set.add(payload.streamId)
broadcastRoomState(currentRoomId)
})
socket.on('watch:stop', (payload) => {
if (!currentRoomId || !currentClientId || !payload?.streamId) return
const room = getOrCreateRuntimeRoom(currentRoomId)
room.watchMap.get(currentClientId)?.delete(payload.streamId)
broadcastRoomState(currentRoomId)
})
const relay = (eventName: 'webrtc:offer' | 'webrtc:answer' | 'webrtc:ice') => {
socket.on(eventName, (payload) => {
if (!currentRoomId || !currentClientId || !payload?.to) return
const room = getOrCreateRuntimeRoom(currentRoomId)
const target = room.sessions.get(payload.to)
if (!target) return
io.to(target.socketId).emit(eventName, {
...payload,
from: currentClientId,
})
})
}
relay('webrtc:offer')
relay('webrtc:answer')
relay('webrtc:ice')
socket.on('disconnect', () => {
cleanup()
})
})
nitroApp.router.use(
'/socket.io/',
defineEventHandler({
handler(event) {
engine.handleRequest(event.node.req, event.node.res)
event._handled = true
},
websocket: {
open(peer) {
// @ts-expect-error engine.io peer bridge
engine.prepare(peer._internal.nodeReq)
// @ts-expect-error engine.io peer bridge
engine.onWebSocket(peer._internal.nodeReq, peer._internal.nodeReq.socket, peer.websocket)
},
},
}),
)
})
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
+24
View File
@@ -0,0 +1,24 @@
import { SignJWT, jwtVerify } from 'jose'
function getSecret() {
const config = useRuntimeConfig()
return new TextEncoder().encode(config.jwtSecret)
}
export async function signAdminToken(username: string) {
return new SignJWT({ role: 'admin', username })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(getSecret())
}
export async function verifyAdminToken(token: string) {
const { payload } = await jwtVerify(token, getSecret())
if (payload.role !== 'admin') {
throw createError({ statusCode: 401, statusMessage: 'Invalid admin token' })
}
return payload as { role: string; username: string }
}
export const ADMIN_COOKIE = 'sejong_admin_token'
+42
View File
@@ -0,0 +1,42 @@
import { SignJWT, jwtVerify } from 'jose'
export interface RoomAccessClaims {
roomId: string
clientId: string
pwdVersion: number
}
function getSecret() {
const config = useRuntimeConfig()
return new TextEncoder().encode(config.jwtSecret)
}
export async function signRoomAccessToken(claims: RoomAccessClaims) {
const config = useRuntimeConfig()
const days = config.roomAccessTtlDays || 7
return new SignJWT({ ...claims, typ: 'room_access' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${days}d`)
.sign(getSecret())
}
export async function verifyRoomAccessToken(
token: string,
expected: { roomId: string; clientId: string; pwdVersion: number },
) {
const { payload } = await jwtVerify(token, getSecret())
if (payload.typ !== 'room_access') {
throw new Error('Invalid token type')
}
if (payload.roomId !== expected.roomId) {
throw new Error('Room mismatch')
}
if (payload.clientId !== expected.clientId) {
throw new Error('Client mismatch')
}
if (Number(payload.pwdVersion) !== expected.pwdVersion) {
throw new Error('Password changed')
}
return payload as unknown as RoomAccessClaims
}
+7
View File
@@ -0,0 +1,7 @@
export function getMaxRoomMembers() {
return useRuntimeConfig().maxRoomMembers || 8
}
export function getMaxConcurrentStreams() {
return useRuntimeConfig().maxConcurrentStreams || 4
}
+117
View File
@@ -0,0 +1,117 @@
import type { ActiveStream, Participant, RoomState, WatchMap } from '~/types/room'
import type { Server } from 'socket.io'
export interface RoomSession {
clientId: string
socketId: string
nickname: string
joinedAt: number
streamId: string | null
}
export interface RuntimeRoom {
roomId: string
sessions: Map<string, RoomSession>
streams: Map<string, ActiveStream>
/** viewerClientId -> Set<streamId> */
watchMap: Map<string, Set<string>>
}
const rooms = new Map<string, RuntimeRoom>()
let ioRef: Server | null = null
export function setIO(io: Server) {
ioRef = io
}
export function getIO() {
return ioRef
}
export function getOrCreateRuntimeRoom(roomId: string): RuntimeRoom {
let room = rooms.get(roomId)
if (!room) {
room = {
roomId,
sessions: new Map(),
streams: new Map(),
watchMap: new Map(),
}
rooms.set(roomId, room)
}
return room
}
export function getRuntimeRoom(roomId: string) {
return rooms.get(roomId)
}
export function deleteRuntimeRoom(roomId: string) {
rooms.delete(roomId)
}
export function serializeWatchMap(room: RuntimeRoom): WatchMap {
const out: WatchMap = {}
for (const [viewerId, streamIds] of room.watchMap) {
out[viewerId] = [...streamIds]
}
return out
}
export function buildRoomState(room: RuntimeRoom): RoomState {
const participants: Participant[] = [...room.sessions.values()].map((s) => ({
clientId: s.clientId,
nickname: s.nickname,
joinedAt: s.joinedAt,
isStreaming: Boolean(s.streamId),
}))
return {
roomId: room.roomId,
participants,
activeStreams: [...room.streams.values()],
watchMap: serializeWatchMap(room),
}
}
export function broadcastRoomState(roomId: string) {
const room = rooms.get(roomId)
const io = ioRef
if (!room || !io) return
io.to(roomId).emit('room:state', buildRoomState(room))
}
export function forceCloseRoom(roomId: string, reason = 'Room deleted') {
const io = ioRef
if (io) {
io.to(roomId).emit('room:force-close', { reason })
io.in(roomId).disconnectSockets(true)
}
deleteRuntimeRoom(roomId)
}
export function removeViewerWatches(room: RuntimeRoom, clientId: string) {
room.watchMap.delete(clientId)
for (const [, set] of room.watchMap) {
// no-op for stream ownership
}
}
export function stopStreamsByClient(room: RuntimeRoom, clientId: string) {
const session = room.sessions.get(clientId)
if (session?.streamId) {
room.streams.delete(session.streamId)
for (const [, set] of room.watchMap) {
set.delete(session.streamId)
}
session.streamId = null
}
}
export function getMemberCount(roomId: string) {
return rooms.get(roomId)?.sessions.size ?? 0
}
export function getStreamCount(roomId: string) {
return rooms.get(roomId)?.streams.size ?? 0
}
+87
View File
@@ -0,0 +1,87 @@
import { defineStore } from 'pinia'
import type { ActiveStream, Participant, RoomState, WatchMap } from '~/types/room'
export const useRoomStore = defineStore('room', () => {
const roomId = ref<string | null>(null)
const roomName = ref('')
const joined = ref(false)
const joinedAt = ref<number | null>(null)
const participants = ref<Participant[]>([])
const activeStreams = ref<ActiveStream[]>([])
const watchMap = ref<WatchMap>({})
const watchingStreamIds = ref<string[]>([])
const layoutMode = ref<'grid' | 'focus'>('grid')
const focusStreamId = ref<string | null>(null)
const volumeMap = ref<Record<string, number>>({})
const mutedMap = ref<Record<string, boolean>>({})
const error = ref<string | null>(null)
function applyState(state: RoomState) {
roomId.value = state.roomId
participants.value = state.participants
activeStreams.value = state.activeStreams
watchMap.value = state.watchMap
}
function setWatching(streamId: string, on: boolean) {
const set = new Set(watchingStreamIds.value)
if (on) set.add(streamId)
else set.delete(streamId)
watchingStreamIds.value = [...set]
if (!on && focusStreamId.value === streamId) {
focusStreamId.value = watchingStreamIds.value[0] || null
}
}
function ensureAudioDefaults(streamId: string) {
if (volumeMap.value[streamId] === undefined) {
volumeMap.value = { ...volumeMap.value, [streamId]: 1 }
}
if (mutedMap.value[streamId] === undefined) {
mutedMap.value = { ...mutedMap.value, [streamId]: false }
}
}
function reset() {
joined.value = false
joinedAt.value = null
participants.value = []
activeStreams.value = []
watchMap.value = {}
watchingStreamIds.value = []
focusStreamId.value = null
error.value = null
}
const viewersOfStream = computed(() => {
const map: Record<string, string[]> = {}
for (const [viewerId, streamIds] of Object.entries(watchMap.value)) {
for (const sid of streamIds) {
if (!map[sid]) map[sid] = []
map[sid].push(viewerId)
}
}
return map
})
return {
roomId,
roomName,
joined,
joinedAt,
participants,
activeStreams,
watchMap,
watchingStreamIds,
layoutMode,
focusStreamId,
volumeMap,
mutedMap,
error,
viewersOfStream,
applyState,
setWatching,
ensureAudioDefaults,
reset,
}
})
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+36
View File
@@ -0,0 +1,36 @@
export interface RoomPublic {
id: string
name: string
createdAt?: string
memberCount?: number
streamCount?: number
}
export interface RoomAdmin extends RoomPublic {
pwdVersion: number
createdBy: string
}
export interface Participant {
clientId: string
nickname: string
joinedAt: number
isStreaming: boolean
}
export interface ActiveStream {
streamId: string
broadcasterId: string
broadcasterNickname: string
startedAt: number
}
/** viewerClientId -> Set of streamIds (serialized as Record) */
export type WatchMap = Record<string, string[]>
export interface RoomState {
roomId: string
participants: Participant[]
activeStreams: ActiveStream[]
watchMap: WatchMap
}
+49
View File
@@ -0,0 +1,49 @@
export interface JoinPayload {
roomId: string
clientId: string
nickname: string
accessToken: string
}
export interface StreamStartPayload {
streamId: string
}
export interface StreamStopPayload {
streamId: string
}
export interface WatchPayload {
streamId: string
broadcasterId: string
}
export interface SignalPayload {
to: string
from?: string
streamId: string
sdp?: RTCSessionDescriptionInit
candidate?: RTCIceCandidateInit
}
export type ClientToServerEvents = {
'room:join': (payload: JoinPayload, ack?: (res: { ok: boolean; error?: string }) => void) => void
'room:leave': () => void
'stream:start': (payload: StreamStartPayload, ack?: (res: { ok: boolean; error?: string }) => void) => void
'stream:stop': (payload: StreamStopPayload) => void
'watch:start': (payload: WatchPayload) => void
'watch:stop': (payload: WatchPayload) => void
'webrtc:offer': (payload: SignalPayload) => void
'webrtc:answer': (payload: SignalPayload) => void
'webrtc:ice': (payload: SignalPayload) => void
}
export type ServerToClientEvents = {
'room:state': (state: import('./room').RoomState) => void
'room:force-close': (payload: { reason: string }) => void
'room:error': (payload: { error: string }) => void
'webrtc:offer': (payload: SignalPayload) => void
'webrtc:answer': (payload: SignalPayload) => void
'webrtc:ice': (payload: SignalPayload) => void
'peer:left': (payload: { clientId: string }) => void
}