raise screen share quality and stop the grid from overflowing
CI / Type-check & Build (push) Successful in 10m31s
CI / Build & Push image (push) Successful in 13m5s

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:
ysj7705andCursor committed 2026-08-12 16:39:04 +09:00
1 parent 14028e5d7c
commit 47a7a717ec
8 files changed
+239 -32

No files matched your search

+11 -7
View File
@@ -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>
+10 -5
View File
@@ -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"
+31 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+55 -2
View File
@@ -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()
})
})
+10
View File
@@ -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)
}
+22
View File
@@ -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] || '?',
}
}