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

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

No files matched your search

+351
View File
@@ -0,0 +1,351 @@
<script setup lang="ts">
import type { ActiveStream, RoomState } from '~/types/room'
const route = useRoute()
const config = useRuntimeConfig()
const roomId = computed(() => String(route.params.roomId || ''))
const store = useRoomStore()
const { clientId } = useClientId()
const { nickname } = useNickname()
const { accessToken, requestAccess, clearAccess } = useRoomAccess(roomId)
const { socket, connect, disconnect } = useSocket()
const { unlockAudio } = useAudioUnlock()
const {
localStream,
streamId: localStreamId,
isSharing,
startShare,
stopShare,
} = useScreenShare()
const roomMeta = ref<{ id: string; name: string } | null>(null)
const showJoin = ref(true)
const needPassword = ref(true)
const joinLoading = ref(false)
const joinError = ref<string | null>(null)
const shareError = ref<string | null>(null)
const localStartedAt = ref<number | null>(null)
const handlersBound = ref(false)
const {
remoteStreams,
createOfferAsViewer,
closePeer,
closeAll,
bindSocketHandlers,
} = useWebRTC({
socket,
clientId,
getLocalStream: () => localStream.value,
})
const maxMembers = computed(() => config.public.maxRoomMembers || 8)
const watchingStreams = computed(() =>
store.activeStreams.filter((s) => store.watchingStreamIds.includes(s.streamId)),
)
const viewerNamesByStream = computed(() => {
const out: Record<string, string[]> = {}
for (const [streamId, viewerIds] of Object.entries(store.viewersOfStream)) {
out[streamId] = viewerIds.map(
(id) => store.participants.find((p) => p.clientId === id)?.nickname || id.slice(0, 6),
)
}
return out
})
const localPreview = computed(() => {
if (!localStream.value || !localStreamId.value) return null
return {
streamId: localStreamId.value,
stream: localStream.value,
title: nickname.value || '나',
startedAt: localStartedAt.value || Date.now(),
}
})
async function loadMeta() {
try {
roomMeta.value = await $fetch(`/api/rooms/${roomId.value}`)
store.roomName = roomMeta.value.name
} catch {
joinError.value = '존재하지 않는 방입니다.'
}
}
async function tryTokenJoin() {
if (!accessToken.value) {
needPassword.value = true
return
}
try {
const res = await requestAccess({
clientId: clientId.value,
accessToken: accessToken.value,
})
roomMeta.value = res.room
store.roomName = res.room.name
needPassword.value = false
// still show dialog for nickname confirm, or auto-join
if (nickname.value) {
await enterRoom(nickname.value)
}
} catch {
clearAccess()
needPassword.value = true
}
}
function bindSocketOnce() {
if (handlersBound.value || !socket.value) return
handlersBound.value = true
bindSocketHandlers()
socket.value.on('room:state', (state: RoomState) => {
store.applyState(state)
})
socket.value.on('room:force-close', ({ reason }) => {
store.error = reason
leaveRoom(false)
alert(reason)
navigateTo('/')
})
socket.value.on('room:error', ({ error }) => {
store.error = error
})
}
async function enterRoom(nick: string, password?: string) {
joinLoading.value = true
joinError.value = null
try {
if (needPassword.value || password) {
await requestAccess({
clientId: clientId.value,
password,
accessToken: accessToken.value || undefined,
})
} else if (accessToken.value) {
await requestAccess({
clientId: clientId.value,
accessToken: accessToken.value,
})
}
connect()
bindSocketOnce()
await new Promise<void>((resolve, reject) => {
const s = socket.value
if (!s) {
reject(new Error('Socket not connected'))
return
}
const doJoin = () => {
s.emit(
'room:join',
{
roomId: roomId.value,
clientId: clientId.value,
nickname: nick,
accessToken: accessToken.value,
},
(res) => {
if (!res?.ok) {
reject(new Error(res?.error || 'Join failed'))
return
}
resolve()
},
)
}
if (s.connected) doJoin()
else s.once('connect', doJoin)
})
nickname.value = nick
store.joined = true
store.joinedAt = Date.now()
showJoin.value = false
needPassword.value = false
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '입장 실패'
joinError.value = msg
if (msg.includes('Password') || msg.includes('password') || msg.includes('Invalid')) {
needPassword.value = true
clearAccess()
}
} finally {
joinLoading.value = false
}
}
async function onJoinSubmit(payload: { nickname: string; password?: string }) {
needPassword.value = Boolean(payload.password) || needPassword.value
await enterRoom(payload.nickname, payload.password)
}
async function toggleShare() {
shareError.value = null
if (isSharing.value) {
const sid = localStreamId.value
stopShare()
if (sid) socket.value?.emit('stream:stop', { streamId: sid })
localStartedAt.value = null
return
}
try {
await unlockAudio()
const { streamId } = await startShare(clientId.value)
await new Promise<void>((resolve, reject) => {
socket.value?.emit('stream:start', { streamId }, (res) => {
if (!res?.ok) reject(new Error(res?.error || 'Stream start failed'))
else resolve()
})
})
localStartedAt.value = Date.now()
store.ensureAudioDefaults(streamId)
store.mutedMap = { ...store.mutedMap, [streamId]: true }
} catch (e: unknown) {
stopShare()
shareError.value = e instanceof Error ? e.message : '화면 공유 실패'
}
}
async function watchStream(stream: ActiveStream) {
await unlockAudio()
store.ensureAudioDefaults(stream.streamId)
store.setWatching(stream.streamId, true)
if (!store.focusStreamId) store.focusStreamId = stream.streamId
socket.value?.emit('watch:start', {
streamId: stream.streamId,
broadcasterId: stream.broadcasterId,
})
await createOfferAsViewer(stream.broadcasterId, stream.streamId)
}
function unwatchStream(stream: ActiveStream) {
store.setWatching(stream.streamId, false)
socket.value?.emit('watch:stop', {
streamId: stream.streamId,
broadcasterId: stream.broadcasterId,
})
closePeer(stream.broadcasterId, stream.streamId)
}
function onStopWatch(streamId: string) {
if (localStreamId.value === streamId) {
toggleShare()
return
}
const stream = store.activeStreams.find((s) => s.streamId === streamId)
if (stream) unwatchStream(stream)
}
function leaveRoom(navigate = true) {
if (isSharing.value) {
const sid = localStreamId.value
stopShare()
if (sid) socket.value?.emit('stream:stop', { streamId: sid })
}
for (const sid of [...store.watchingStreamIds]) {
const stream = store.activeStreams.find((s) => s.streamId === sid)
if (stream) unwatchStream(stream)
}
socket.value?.emit('room:leave')
closeAll()
store.reset()
disconnect()
handlersBound.value = false
showJoin.value = true
if (navigate) navigateTo('/')
}
onMounted(async () => {
await loadMeta()
await tryTokenJoin()
})
onBeforeUnmount(() => {
leaveRoom(false)
})
</script>
<template>
<div class="flex h-screen flex-col">
<RoomJoinDialog
v-if="showJoin"
:room-name="roomMeta?.name || store.roomName || roomId"
:need-password="needPassword"
:loading="joinLoading"
:error="joinError"
@submit="onJoinSubmit"
/>
<template v-if="store.joined">
<RoomHeader
:room-name="store.roomName || roomMeta?.name || roomId"
:nickname="nickname"
:joined-at="store.joinedAt"
:is-sharing="isSharing"
:member-count="store.participants.length"
:max-members="maxMembers"
:layout-mode="store.layoutMode"
@toggle-share="toggleShare"
@toggle-layout="store.layoutMode = store.layoutMode === 'grid' ? 'focus' : 'grid'"
@leave="leaveRoom()"
/>
<p v-if="shareError" class="bg-[var(--danger)]/20 px-4 py-2 text-sm text-[var(--danger)]">
{{ shareError }}
</p>
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[260px_1fr]">
<RoomParticipantList
class="hidden md:flex"
:participants="store.participants"
:active-streams="store.activeStreams"
:watch-map="store.watchMap"
:my-client-id="clientId"
:watching-stream-ids="store.watchingStreamIds"
@watch="watchStream"
@unwatch="unwatchStream"
/>
<main class="min-h-0 overflow-auto p-3">
<div class="mb-3 md:hidden">
<RoomParticipantList
:participants="store.participants"
:active-streams="store.activeStreams"
:watch-map="store.watchMap"
:my-client-id="clientId"
:watching-stream-ids="store.watchingStreamIds"
@watch="watchStream"
@unwatch="unwatchStream"
/>
</div>
<RoomStreamGrid
:streams="watchingStreams"
:remote-streams="remoteStreams"
:local-preview="localPreview"
:layout-mode="store.layoutMode"
:focus-stream-id="store.focusStreamId"
:volume-map="store.volumeMap"
:muted-map="store.mutedMap"
:viewer-names-by-stream="viewerNamesByStream"
@update:volume="(id, v) => (store.volumeMap = { ...store.volumeMap, [id]: v })"
@update:muted="(id, v) => (store.mutedMap = { ...store.mutedMap, [id]: v })"
@stop="onStopWatch"
@focus="store.focusStreamId = $event"
/>
</main>
</div>
</template>
</div>
</template>