first commit
This commit is contained in:
commit
69b647145f
58 files changed
+15796
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
|
|
||||||
|
export function useNickname() {
|
||||||
|
const nickname = useLocalStorage('sejong.nickname', '')
|
||||||
|
return { nickname }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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')}`
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
npx prisma migrate deploy
|
||||||
|
exec node .output/server/index.mjs
|
||||||
@@ -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에서 화면 공유(시스템 오디오 포함) 테스트
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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: '멀티 스크린 화면 공유 플랫폼' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+13090
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
@@ -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 |
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ADMIN_COOKIE } from '../../utils/jwt'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
deleteCookie(event, ADMIN_COOKIE, { path: '/' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 },
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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),
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -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}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function getMaxRoomMembers() {
|
||||||
|
return useRuntimeConfig().maxRoomMembers || 8
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMaxConcurrentStreams() {
|
||||||
|
return useRuntimeConfig().maxConcurrentStreams || 4
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user