raise screen share quality and stop the grid from overflowing
Screen share was capped by WebRTC's default ~2.5 Mbps and by window capture resolution, and fixed aspect-video cards forced the page to scroll. Adds minimal [sejong-rtc] logs to diagnose ICE failures. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
14028e5d7c
commit
47a7a717ec
8 files changed
+239
-32
No files matched your search
@@ -58,15 +58,19 @@ const cards = computed(() => {
|
||||
return list
|
||||
})
|
||||
|
||||
// Cells stretch to the available height; the video letterboxes itself with object-contain,
|
||||
// so the aspect ratio is kept without forcing the page to scroll.
|
||||
const base = 'grid h-full min-h-0 gap-3 auto-rows-[minmax(220px,1fr)]'
|
||||
|
||||
const gridClass = computed(() => {
|
||||
if (props.layoutMode === 'focus') {
|
||||
return 'grid grid-cols-1 gap-3 lg:grid-cols-4'
|
||||
return `${base} grid-cols-1 lg:grid-cols-4 lg:grid-rows-3`
|
||||
}
|
||||
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'
|
||||
if (n <= 1) return `${base} grid-cols-1`
|
||||
if (n === 2) return `${base} grid-cols-1 md:grid-cols-2`
|
||||
if (n <= 4) return `${base} grid-cols-1 md:grid-cols-2`
|
||||
return `${base} grid-cols-1 md:grid-cols-2 xl:grid-cols-3`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -75,7 +79,7 @@ const gridClass = computed(() => {
|
||||
<RoomVideoPlayerCard
|
||||
v-for="(card, index) in cards"
|
||||
:key="card.streamId"
|
||||
:class="layoutMode === 'focus' && index === 0 ? 'lg:col-span-3 lg:row-span-2' : ''"
|
||||
:class="layoutMode === 'focus' && index === 0 ? 'lg:col-span-3 lg:row-span-3' : ''"
|
||||
:stream-id="card.streamId"
|
||||
:title="card.isLocal ? `${card.title} (미리보기)` : card.title"
|
||||
:media-stream="card.mediaStream"
|
||||
@@ -91,7 +95,7 @@ const gridClass = computed(() => {
|
||||
/>
|
||||
<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)]"
|
||||
class="flex items-center justify-center rounded-lg border border-dashed border-[var(--border)] text-sm text-[var(--text-muted)]"
|
||||
>
|
||||
시청할 화면을 왼쪽에서 선택하세요.
|
||||
</div>
|
||||
|
||||
@@ -84,11 +84,16 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-[var(--border)] bg-black/40"
|
||||
class="flex min-h-0 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="relative min-h-0 flex-1 bg-black" @dblclick="emit('focus')">
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="absolute inset-0 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>
|
||||
@@ -119,7 +124,7 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
v-if="focused"
|
||||
class="flex flex-wrap items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2"
|
||||
class="flex shrink-0 flex-wrap items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -154,7 +159,7 @@ onMounted(() => {
|
||||
<p v-if="dvrError" class="w-full text-xs text-[var(--danger)]">{{ dvrError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 py-2">
|
||||
<div class="flex shrink-0 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"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { rtcLog, rtcWarn } from '~/utils/rtcLog'
|
||||
|
||||
export function useScreenShare() {
|
||||
const localStream = ref<MediaStream | null>(null)
|
||||
const streamId = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const settings = ref<MediaTrackSettings | null>(null)
|
||||
const isSharing = computed(() => Boolean(localStream.value))
|
||||
|
||||
async function startShare(clientId: string) {
|
||||
@@ -9,11 +12,12 @@ export function useScreenShare() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
width: { ideal: 1920, max: 1920 },
|
||||
height: { ideal: 1080, max: 1080 },
|
||||
frameRate: { ideal: 60, max: 60 },
|
||||
// Monitor capture reaches full 1080p; window capture is capped by the window size.
|
||||
// @ts-expect-error displaySurface is supported in Chromium
|
||||
displaySurface: 'window',
|
||||
displaySurface: 'monitor',
|
||||
},
|
||||
audio: {
|
||||
// @ts-expect-error Chromium system audio options
|
||||
@@ -27,9 +31,30 @@ export function useScreenShare() {
|
||||
streamId.value = `${clientId}-${Date.now()}`
|
||||
|
||||
const videoTrack = stream.getVideoTracks()[0]
|
||||
videoTrack?.addEventListener('ended', () => {
|
||||
if (videoTrack) {
|
||||
// Games favour framerate over per-frame sharpness.
|
||||
videoTrack.contentHint = 'motion'
|
||||
try {
|
||||
await videoTrack.applyConstraints({
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
frameRate: { ideal: 60 },
|
||||
})
|
||||
} catch (e) {
|
||||
rtcWarn('applyConstraints failed', String(e))
|
||||
}
|
||||
settings.value = videoTrack.getSettings()
|
||||
rtcLog('capture:settings', settings.value)
|
||||
if ((settings.value.height || 0) < 1080) {
|
||||
rtcWarn(
|
||||
'capture below 1080p — pick a full monitor (window capture is limited to the window size)',
|
||||
{ width: settings.value.width, height: settings.value.height },
|
||||
)
|
||||
}
|
||||
videoTrack.addEventListener('ended', () => {
|
||||
stopShare()
|
||||
})
|
||||
}
|
||||
|
||||
return { stream, streamId: streamId.value }
|
||||
} catch (e) {
|
||||
@@ -42,11 +67,12 @@ export function useScreenShare() {
|
||||
localStream.value?.getTracks().forEach((t) => t.stop())
|
||||
localStream.value = null
|
||||
streamId.value = null
|
||||
settings.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopShare()
|
||||
})
|
||||
|
||||
return { localStream, streamId, isSharing, error, startShare, stopShare }
|
||||
return { localStream, streamId, settings, isSharing, error, startShare, stopShare }
|
||||
}
|
||||
+88
-11
@@ -1,4 +1,5 @@
|
||||
import type { AppSocket } from './useSocket'
|
||||
import { iceSummary, rtcLog, rtcWarn } from '~/utils/rtcLog'
|
||||
|
||||
type PeerKey = string
|
||||
|
||||
@@ -6,6 +7,9 @@ function peerKey(remoteId: string, streamId: string) {
|
||||
return `${remoteId}:${streamId}`
|
||||
}
|
||||
|
||||
/** WebRTC defaults cap screen share near 2.5 Mbps, which looks soft at 1080p60. */
|
||||
const MAX_VIDEO_BITRATE = 12_000_000
|
||||
|
||||
export function useWebRTC(options: {
|
||||
socket: Ref<AppSocket | null>
|
||||
clientId: Ref<string>
|
||||
@@ -15,16 +19,40 @@ export function useWebRTC(options: {
|
||||
const remoteStreams = ref<Record<string, MediaStream>>({})
|
||||
const iceServers: RTCIceServer[] = [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
|
||||
async function applySenderQuality(pc: RTCPeerConnection, key: string) {
|
||||
for (const sender of pc.getSenders()) {
|
||||
if (sender.track?.kind !== 'video') continue
|
||||
const params = sender.getParameters()
|
||||
if (!params.encodings || params.encodings.length === 0) {
|
||||
params.encodings = [{}]
|
||||
}
|
||||
params.encodings[0]!.maxBitrate = MAX_VIDEO_BITRATE
|
||||
params.encodings[0]!.maxFramerate = 60
|
||||
params.degradationPreference = 'maintain-framerate'
|
||||
try {
|
||||
await sender.setParameters(params)
|
||||
rtcLog('sender:quality', { key, maxBitrate: MAX_VIDEO_BITRATE, maxFramerate: 60 })
|
||||
} catch (e) {
|
||||
rtcWarn('sender:setParameters failed', { key, err: String(e) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPc(remoteId: string, streamId: string) {
|
||||
const key = peerKey(remoteId, streamId)
|
||||
let pc = peers.get(key)
|
||||
if (pc) return pc
|
||||
|
||||
rtcLog('pc:create', { key, iceServers })
|
||||
pc = new RTCPeerConnection({ iceServers })
|
||||
peers.set(key, pc)
|
||||
|
||||
pc.onicecandidate = (ev) => {
|
||||
if (!ev.candidate || !options.socket.value) return
|
||||
if (!ev.candidate || !options.socket.value) {
|
||||
if (!ev.candidate) rtcLog('ice:local gathering done', { key })
|
||||
return
|
||||
}
|
||||
rtcLog('ice:local', { key, ...iceSummary(ev.candidate.toJSON()) })
|
||||
options.socket.value.emit('webrtc:ice', {
|
||||
to: remoteId,
|
||||
streamId,
|
||||
@@ -32,17 +60,33 @@ export function useWebRTC(options: {
|
||||
})
|
||||
}
|
||||
|
||||
pc.ontrack = (ev) => {
|
||||
const stream = ev.streams[0] || new MediaStream([ev.track])
|
||||
remoteStreams.value = { ...remoteStreams.value, [streamId]: stream }
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
rtcLog('ice:state', { key, state: pc?.iceConnectionState })
|
||||
if (pc?.iceConnectionState === 'failed') {
|
||||
rtcWarn('ice:FAILED — same-LAN check firewall/AP isolation; else need TURN', { key })
|
||||
}
|
||||
}
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
rtcLog('pc:state', { key, state: pc?.connectionState })
|
||||
if (pc?.connectionState === 'failed' || pc?.connectionState === 'closed') {
|
||||
closePeer(remoteId, streamId)
|
||||
}
|
||||
}
|
||||
|
||||
pc.ontrack = (ev) => {
|
||||
const stream = ev.streams[0] || new MediaStream([ev.track])
|
||||
rtcLog('track:remote', {
|
||||
key,
|
||||
kind: ev.track.kind,
|
||||
id: ev.track.id,
|
||||
muted: ev.track.muted,
|
||||
readyState: ev.track.readyState,
|
||||
streamTracks: stream.getTracks().map((t) => t.kind),
|
||||
})
|
||||
remoteStreams.value = { ...remoteStreams.value, [streamId]: stream }
|
||||
}
|
||||
|
||||
return pc
|
||||
}
|
||||
|
||||
@@ -50,6 +94,7 @@ export function useWebRTC(options: {
|
||||
const key = peerKey(remoteId, streamId)
|
||||
const pc = peers.get(key)
|
||||
if (pc) {
|
||||
rtcLog('pc:close', { key })
|
||||
pc.close()
|
||||
peers.delete(key)
|
||||
}
|
||||
@@ -61,6 +106,7 @@ export function useWebRTC(options: {
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
rtcLog('pc:closeAll', { count: peers.size })
|
||||
for (const pc of peers.values()) pc.close()
|
||||
peers.clear()
|
||||
remoteStreams.value = {}
|
||||
@@ -68,13 +114,17 @@ export function useWebRTC(options: {
|
||||
|
||||
async function createOfferAsViewer(broadcasterId: string, streamId: string) {
|
||||
const pc = getPc(broadcasterId, streamId)
|
||||
// Without recvonly transceivers the offer carries no m-lines and no media can flow back.
|
||||
if (pc.getTransceivers().length === 0) {
|
||||
pc.addTransceiver('video', { direction: 'recvonly' })
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||
}
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
rtcLog('offer:send', {
|
||||
to: broadcasterId,
|
||||
streamId,
|
||||
mLines: (offer.sdp?.match(/^m=/gm) || []).length,
|
||||
})
|
||||
options.socket.value?.emit('webrtc:offer', {
|
||||
to: broadcasterId,
|
||||
streamId,
|
||||
@@ -84,17 +134,34 @@ export function useWebRTC(options: {
|
||||
|
||||
async function handleOffer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
||||
const pc = getPc(from, streamId)
|
||||
// Set the remote offer first so addTrack reuses the viewer's recvonly transceivers.
|
||||
rtcLog('offer:recv', {
|
||||
from,
|
||||
streamId,
|
||||
mLines: (sdp.sdp?.match(/^m=/gm) || []).length,
|
||||
hasLocalStream: Boolean(options.getLocalStream()),
|
||||
localTracks: options.getLocalStream()?.getTracks().map((t) => t.kind) || [],
|
||||
})
|
||||
await pc.setRemoteDescription(sdp)
|
||||
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)
|
||||
if (!exists) {
|
||||
pc.addTrack(track, local)
|
||||
rtcLog('track:add local', { to: from, streamId, kind: track.kind })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rtcWarn('offer:recv but no local MediaStream to send', { from, streamId })
|
||||
}
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
await applySenderQuality(pc, peerKey(from, streamId))
|
||||
rtcLog('answer:send', {
|
||||
to: from,
|
||||
streamId,
|
||||
mLines: (answer.sdp?.match(/^m=/gm) || []).length,
|
||||
})
|
||||
options.socket.value?.emit('webrtc:answer', {
|
||||
to: from,
|
||||
streamId,
|
||||
@@ -104,21 +171,29 @@ export function useWebRTC(options: {
|
||||
|
||||
async function handleAnswer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
||||
const pc = peers.get(peerKey(from, streamId))
|
||||
if (!pc) return
|
||||
if (!pc) {
|
||||
rtcWarn('answer:recv but no PC', { from, streamId })
|
||||
return
|
||||
}
|
||||
rtcLog('answer:recv', {
|
||||
from,
|
||||
streamId,
|
||||
mLines: (sdp.sdp?.match(/^m=/gm) || []).length,
|
||||
})
|
||||
await pc.setRemoteDescription(sdp)
|
||||
}
|
||||
|
||||
async function handleIce(from: string, streamId: string, candidate: RTCIceCandidateInit) {
|
||||
const pc = peers.get(peerKey(from, streamId))
|
||||
if (!pc) return
|
||||
rtcLog('ice:remote', { from, streamId, ...iceSummary(candidate) })
|
||||
try {
|
||||
await pc.addIceCandidate(candidate)
|
||||
} catch {
|
||||
// ignore late candidates
|
||||
} catch (e) {
|
||||
rtcWarn('ice:add failed', { from, streamId, err: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -132,6 +207,7 @@ export function useWebRTC(options: {
|
||||
function bindSocketHandlers() {
|
||||
const s = options.socket.value
|
||||
if (!s) return
|
||||
rtcLog('socket:bind webrtc handlers', { clientId: options.clientId.value })
|
||||
|
||||
s.on('webrtc:offer', async (payload) => {
|
||||
if (!payload.from || !payload.sdp || !payload.streamId) return
|
||||
@@ -149,6 +225,7 @@ export function useWebRTC(options: {
|
||||
})
|
||||
|
||||
s.on('peer:left', (payload) => {
|
||||
rtcLog('peer:left', payload)
|
||||
for (const key of [...peers.keys()]) {
|
||||
if (key.startsWith(`${payload.clientId}:`)) {
|
||||
const streamId = key.split(':')[1]
|
||||
|
||||
+12
-2
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActiveStream, RoomState } from '~/types/room'
|
||||
import { rtcLog } from '~/utils/rtcLog'
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
@@ -218,6 +219,10 @@ async function toggleShare() {
|
||||
}
|
||||
|
||||
async function watchStream(stream: ActiveStream) {
|
||||
rtcLog('watch:start local', {
|
||||
streamId: stream.streamId,
|
||||
broadcasterId: stream.broadcasterId,
|
||||
})
|
||||
await unlockAudio()
|
||||
store.ensureAudioDefaults(stream.streamId)
|
||||
store.setWatching(stream.streamId, true)
|
||||
@@ -230,6 +235,10 @@ async function watchStream(stream: ActiveStream) {
|
||||
}
|
||||
|
||||
function unwatchStream(stream: ActiveStream) {
|
||||
rtcLog('watch:stop local', {
|
||||
streamId: stream.streamId,
|
||||
broadcasterId: stream.broadcasterId,
|
||||
})
|
||||
store.setWatching(stream.streamId, false)
|
||||
socket.value?.emit('watch:stop', {
|
||||
streamId: stream.streamId,
|
||||
@@ -317,8 +326,8 @@ onBeforeUnmount(() => {
|
||||
@unwatch="unwatchStream"
|
||||
/>
|
||||
|
||||
<main class="min-h-0 overflow-auto p-3">
|
||||
<div class="mb-3 md:hidden">
|
||||
<main class="flex min-h-0 flex-col gap-3 overflow-auto p-3 md:overflow-hidden">
|
||||
<div class="shrink-0 md:hidden">
|
||||
<RoomParticipantList
|
||||
:participants="store.participants"
|
||||
:active-streams="store.activeStreams"
|
||||
@@ -331,6 +340,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<RoomStreamGrid
|
||||
class="min-h-0 flex-1"
|
||||
:streams="watchingStreams"
|
||||
:remote-streams="remoteStreams"
|
||||
:local-preview="localPreview"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
stopStreamsByClient,
|
||||
type RoomSession,
|
||||
} from '../utils/runtimeRooms'
|
||||
import { rtcLog, rtcWarn } from '../utils/rtcLog'
|
||||
|
||||
export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
const engine = new Engine()
|
||||
@@ -23,13 +24,16 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
})
|
||||
io.bind(engine)
|
||||
setIO(io)
|
||||
rtcLog('socket.io plugin ready')
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
let currentRoomId: string | null = null
|
||||
let currentClientId: string | null = null
|
||||
rtcLog('socket:connect', { socketId: socket.id })
|
||||
|
||||
const cleanup = () => {
|
||||
if (!currentRoomId || !currentClientId) return
|
||||
rtcLog('socket:cleanup', { roomId: currentRoomId, clientId: currentClientId })
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
const session = room.sessions.get(currentClientId)
|
||||
if (session && session.socketId === socket.id) {
|
||||
@@ -98,12 +102,19 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
currentRoomId = roomId
|
||||
currentClientId = clientId
|
||||
socket.join(roomId)
|
||||
rtcLog('room:join', {
|
||||
roomId,
|
||||
clientId,
|
||||
nickname: session.nickname,
|
||||
members: room.sessions.size,
|
||||
})
|
||||
|
||||
ack?.({ ok: true })
|
||||
socket.emit('room:state', buildRoomState(room))
|
||||
broadcastRoomState(roomId)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Join failed'
|
||||
rtcWarn('room:join failed', { error: message })
|
||||
ack?.({ ok: false, error: message })
|
||||
}
|
||||
})
|
||||
@@ -141,6 +152,12 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
broadcasterNickname: session.nickname,
|
||||
startedAt: Date.now(),
|
||||
})
|
||||
rtcLog('stream:start', {
|
||||
roomId: currentRoomId,
|
||||
clientId: currentClientId,
|
||||
streamId,
|
||||
streams: room.streams.size,
|
||||
})
|
||||
ack?.({ ok: true })
|
||||
broadcastRoomState(currentRoomId)
|
||||
})
|
||||
@@ -156,19 +173,33 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
for (const set of room.watchMap.values()) {
|
||||
set.delete(streamId)
|
||||
}
|
||||
rtcLog('stream:stop', { roomId: currentRoomId, clientId: currentClientId, 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
|
||||
if (!room.streams.has(payload.streamId)) {
|
||||
rtcWarn('watch:start unknown stream', {
|
||||
roomId: currentRoomId,
|
||||
clientId: currentClientId,
|
||||
streamId: payload.streamId,
|
||||
})
|
||||
return
|
||||
}
|
||||
let set = room.watchMap.get(currentClientId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
room.watchMap.set(currentClientId, set)
|
||||
}
|
||||
set.add(payload.streamId)
|
||||
rtcLog('watch:start', {
|
||||
roomId: currentRoomId,
|
||||
viewer: currentClientId,
|
||||
streamId: payload.streamId,
|
||||
broadcasterId: payload.broadcasterId,
|
||||
})
|
||||
broadcastRoomState(currentRoomId)
|
||||
})
|
||||
|
||||
@@ -176,6 +207,11 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
if (!currentRoomId || !currentClientId || !payload?.streamId) return
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
room.watchMap.get(currentClientId)?.delete(payload.streamId)
|
||||
rtcLog('watch:stop', {
|
||||
roomId: currentRoomId,
|
||||
viewer: currentClientId,
|
||||
streamId: payload.streamId,
|
||||
})
|
||||
broadcastRoomState(currentRoomId)
|
||||
})
|
||||
|
||||
@@ -184,7 +220,23 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
if (!currentRoomId || !currentClientId || !payload?.to) return
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
const target = room.sessions.get(payload.to)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
rtcWarn('relay: target offline', {
|
||||
event: eventName,
|
||||
from: currentClientId,
|
||||
to: payload.to,
|
||||
roomId: currentRoomId,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (eventName !== 'webrtc:ice') {
|
||||
rtcLog('relay', {
|
||||
event: eventName,
|
||||
from: currentClientId,
|
||||
to: payload.to,
|
||||
streamId: payload.streamId,
|
||||
})
|
||||
}
|
||||
io.to(target.socketId).emit(eventName, {
|
||||
...payload,
|
||||
from: currentClientId,
|
||||
@@ -196,6 +248,7 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
relay('webrtc:ice')
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
rtcLog('socket:disconnect', { socketId: socket.id, clientId: currentClientId })
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Server-side minimal signaling / WebRTC relay logs. */
|
||||
export function rtcLog(...args: unknown[]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[sejong-rtc]', ...args)
|
||||
}
|
||||
|
||||
export function rtcWarn(...args: unknown[]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[sejong-rtc]', ...args)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Minimal debug logger for WebRTC / room signaling. Filter console by: sejong-rtc */
|
||||
export function rtcLog(...args: unknown[]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[sejong-rtc]', ...args)
|
||||
}
|
||||
|
||||
export function rtcWarn(...args: unknown[]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[sejong-rtc]', ...args)
|
||||
}
|
||||
|
||||
/** Summarize ICE candidate for logs without dumping full SDP. */
|
||||
export function iceSummary(candidate?: RTCIceCandidateInit | null) {
|
||||
if (!candidate?.candidate) return { endOfCandidates: true }
|
||||
const parts = candidate.candidate.split(' ')
|
||||
return {
|
||||
type: parts[7] || '?',
|
||||
protocol: parts[2] || '?',
|
||||
address: parts[4] || '?',
|
||||
port: parts[5] || '?',
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user