first commit
This commit is contained in:
commit
69b647145f
58 files changed
+15796
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user