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
+241
-34
No files matched your search
@@ -58,15 +58,19 @@ const cards = computed(() => {
|
|||||||
return list
|
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(() => {
|
const gridClass = computed(() => {
|
||||||
if (props.layoutMode === 'focus') {
|
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
|
const n = cards.value.length
|
||||||
if (n <= 1) return 'grid grid-cols-1 gap-3'
|
if (n <= 1) return `${base} grid-cols-1`
|
||||||
if (n === 2) return 'grid grid-cols-1 gap-3 md:grid-cols-2'
|
if (n === 2) return `${base} grid-cols-1 md:grid-cols-2`
|
||||||
if (n <= 4) return 'grid grid-cols-1 gap-3 md:grid-cols-2'
|
if (n <= 4) return `${base} grid-cols-1 md:grid-cols-2`
|
||||||
return 'grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3'
|
return `${base} grid-cols-1 md:grid-cols-2 xl:grid-cols-3`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -75,7 +79,7 @@ const gridClass = computed(() => {
|
|||||||
<RoomVideoPlayerCard
|
<RoomVideoPlayerCard
|
||||||
v-for="(card, index) in cards"
|
v-for="(card, index) in cards"
|
||||||
:key="card.streamId"
|
: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"
|
:stream-id="card.streamId"
|
||||||
:title="card.isLocal ? `${card.title} (미리보기)` : card.title"
|
:title="card.isLocal ? `${card.title} (미리보기)` : card.title"
|
||||||
:media-stream="card.mediaStream"
|
:media-stream="card.mediaStream"
|
||||||
@@ -91,7 +95,7 @@ const gridClass = computed(() => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!cards.length"
|
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>
|
</div>
|
||||||
|
|||||||
@@ -84,11 +84,16 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article
|
<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)]' : ''"
|
:class="focused ? 'ring-2 ring-[var(--accent)]' : ''"
|
||||||
>
|
>
|
||||||
<div class="relative aspect-video bg-black" @dblclick="emit('focus')">
|
<div class="relative min-h-0 flex-1 bg-black" @dblclick="emit('focus')">
|
||||||
<video ref="videoRef" class="h-full w-full object-contain" autoplay playsinline />
|
<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">
|
<div class="pointer-events-none absolute left-2 top-2 rounded bg-black/60 px-2 py-1 text-xs">
|
||||||
{{ title }} · {{ formatted }}
|
{{ title }} · {{ formatted }}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +124,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="focused"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -154,7 +159,7 @@ onMounted(() => {
|
|||||||
<p v-if="dvrError" class="w-full text-xs text-[var(--danger)]">{{ dvrError }}</p>
|
<p v-if="dvrError" class="w-full text-xs text-[var(--danger)]">{{ dvrError }}</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-ghost !px-2 !py-1 text-xs"
|
class="btn-ghost !px-2 !py-1 text-xs"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { rtcLog, rtcWarn } from '~/utils/rtcLog'
|
||||||
|
|
||||||
export function useScreenShare() {
|
export function useScreenShare() {
|
||||||
const localStream = ref<MediaStream | null>(null)
|
const localStream = ref<MediaStream | null>(null)
|
||||||
const streamId = ref<string | null>(null)
|
const streamId = ref<string | null>(null)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const settings = ref<MediaTrackSettings | null>(null)
|
||||||
const isSharing = computed(() => Boolean(localStream.value))
|
const isSharing = computed(() => Boolean(localStream.value))
|
||||||
|
|
||||||
async function startShare(clientId: string) {
|
async function startShare(clientId: string) {
|
||||||
@@ -9,11 +12,12 @@ export function useScreenShare() {
|
|||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
video: {
|
video: {
|
||||||
width: { ideal: 1920 },
|
width: { ideal: 1920, max: 1920 },
|
||||||
height: { ideal: 1080 },
|
height: { ideal: 1080, max: 1080 },
|
||||||
frameRate: { ideal: 60, max: 60 },
|
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
|
// @ts-expect-error displaySurface is supported in Chromium
|
||||||
displaySurface: 'window',
|
displaySurface: 'monitor',
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
// @ts-expect-error Chromium system audio options
|
// @ts-expect-error Chromium system audio options
|
||||||
@@ -27,9 +31,30 @@ export function useScreenShare() {
|
|||||||
streamId.value = `${clientId}-${Date.now()}`
|
streamId.value = `${clientId}-${Date.now()}`
|
||||||
|
|
||||||
const videoTrack = stream.getVideoTracks()[0]
|
const videoTrack = stream.getVideoTracks()[0]
|
||||||
videoTrack?.addEventListener('ended', () => {
|
if (videoTrack) {
|
||||||
stopShare()
|
// 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 }
|
return { stream, streamId: streamId.value }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -42,11 +67,12 @@ export function useScreenShare() {
|
|||||||
localStream.value?.getTracks().forEach((t) => t.stop())
|
localStream.value?.getTracks().forEach((t) => t.stop())
|
||||||
localStream.value = null
|
localStream.value = null
|
||||||
streamId.value = null
|
streamId.value = null
|
||||||
|
settings.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopShare()
|
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 type { AppSocket } from './useSocket'
|
||||||
|
import { iceSummary, rtcLog, rtcWarn } from '~/utils/rtcLog'
|
||||||
|
|
||||||
type PeerKey = string
|
type PeerKey = string
|
||||||
|
|
||||||
@@ -6,6 +7,9 @@ function peerKey(remoteId: string, streamId: string) {
|
|||||||
return `${remoteId}:${streamId}`
|
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: {
|
export function useWebRTC(options: {
|
||||||
socket: Ref<AppSocket | null>
|
socket: Ref<AppSocket | null>
|
||||||
clientId: Ref<string>
|
clientId: Ref<string>
|
||||||
@@ -15,16 +19,40 @@ export function useWebRTC(options: {
|
|||||||
const remoteStreams = ref<Record<string, MediaStream>>({})
|
const remoteStreams = ref<Record<string, MediaStream>>({})
|
||||||
const iceServers: RTCIceServer[] = [{ urls: 'stun:stun.l.google.com:19302' }]
|
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) {
|
function getPc(remoteId: string, streamId: string) {
|
||||||
const key = peerKey(remoteId, streamId)
|
const key = peerKey(remoteId, streamId)
|
||||||
let pc = peers.get(key)
|
let pc = peers.get(key)
|
||||||
if (pc) return pc
|
if (pc) return pc
|
||||||
|
|
||||||
|
rtcLog('pc:create', { key, iceServers })
|
||||||
pc = new RTCPeerConnection({ iceServers })
|
pc = new RTCPeerConnection({ iceServers })
|
||||||
peers.set(key, pc)
|
peers.set(key, pc)
|
||||||
|
|
||||||
pc.onicecandidate = (ev) => {
|
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', {
|
options.socket.value.emit('webrtc:ice', {
|
||||||
to: remoteId,
|
to: remoteId,
|
||||||
streamId,
|
streamId,
|
||||||
@@ -32,17 +60,33 @@ export function useWebRTC(options: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pc.ontrack = (ev) => {
|
pc.oniceconnectionstatechange = () => {
|
||||||
const stream = ev.streams[0] || new MediaStream([ev.track])
|
rtcLog('ice:state', { key, state: pc?.iceConnectionState })
|
||||||
remoteStreams.value = { ...remoteStreams.value, [streamId]: stream }
|
if (pc?.iceConnectionState === 'failed') {
|
||||||
|
rtcWarn('ice:FAILED — same-LAN check firewall/AP isolation; else need TURN', { key })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
|
rtcLog('pc:state', { key, state: pc?.connectionState })
|
||||||
if (pc?.connectionState === 'failed' || pc?.connectionState === 'closed') {
|
if (pc?.connectionState === 'failed' || pc?.connectionState === 'closed') {
|
||||||
closePeer(remoteId, streamId)
|
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
|
return pc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +94,7 @@ export function useWebRTC(options: {
|
|||||||
const key = peerKey(remoteId, streamId)
|
const key = peerKey(remoteId, streamId)
|
||||||
const pc = peers.get(key)
|
const pc = peers.get(key)
|
||||||
if (pc) {
|
if (pc) {
|
||||||
|
rtcLog('pc:close', { key })
|
||||||
pc.close()
|
pc.close()
|
||||||
peers.delete(key)
|
peers.delete(key)
|
||||||
}
|
}
|
||||||
@@ -61,6 +106,7 @@ export function useWebRTC(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAll() {
|
function closeAll() {
|
||||||
|
rtcLog('pc:closeAll', { count: peers.size })
|
||||||
for (const pc of peers.values()) pc.close()
|
for (const pc of peers.values()) pc.close()
|
||||||
peers.clear()
|
peers.clear()
|
||||||
remoteStreams.value = {}
|
remoteStreams.value = {}
|
||||||
@@ -68,13 +114,17 @@ export function useWebRTC(options: {
|
|||||||
|
|
||||||
async function createOfferAsViewer(broadcasterId: string, streamId: string) {
|
async function createOfferAsViewer(broadcasterId: string, streamId: string) {
|
||||||
const pc = getPc(broadcasterId, streamId)
|
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) {
|
if (pc.getTransceivers().length === 0) {
|
||||||
pc.addTransceiver('video', { direction: 'recvonly' })
|
pc.addTransceiver('video', { direction: 'recvonly' })
|
||||||
pc.addTransceiver('audio', { direction: 'recvonly' })
|
pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||||
}
|
}
|
||||||
const offer = await pc.createOffer()
|
const offer = await pc.createOffer()
|
||||||
await pc.setLocalDescription(offer)
|
await pc.setLocalDescription(offer)
|
||||||
|
rtcLog('offer:send', {
|
||||||
|
to: broadcasterId,
|
||||||
|
streamId,
|
||||||
|
mLines: (offer.sdp?.match(/^m=/gm) || []).length,
|
||||||
|
})
|
||||||
options.socket.value?.emit('webrtc:offer', {
|
options.socket.value?.emit('webrtc:offer', {
|
||||||
to: broadcasterId,
|
to: broadcasterId,
|
||||||
streamId,
|
streamId,
|
||||||
@@ -84,17 +134,34 @@ export function useWebRTC(options: {
|
|||||||
|
|
||||||
async function handleOffer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
async function handleOffer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
||||||
const pc = getPc(from, streamId)
|
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)
|
await pc.setRemoteDescription(sdp)
|
||||||
const local = options.getLocalStream()
|
const local = options.getLocalStream()
|
||||||
if (local) {
|
if (local) {
|
||||||
for (const track of local.getTracks()) {
|
for (const track of local.getTracks()) {
|
||||||
const exists = pc.getSenders().some((s) => s.track?.id === track.id)
|
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()
|
const answer = await pc.createAnswer()
|
||||||
await pc.setLocalDescription(answer)
|
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', {
|
options.socket.value?.emit('webrtc:answer', {
|
||||||
to: from,
|
to: from,
|
||||||
streamId,
|
streamId,
|
||||||
@@ -104,21 +171,29 @@ export function useWebRTC(options: {
|
|||||||
|
|
||||||
async function handleAnswer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
async function handleAnswer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
||||||
const pc = peers.get(peerKey(from, streamId))
|
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)
|
await pc.setRemoteDescription(sdp)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIce(from: string, streamId: string, candidate: RTCIceCandidateInit) {
|
async function handleIce(from: string, streamId: string, candidate: RTCIceCandidateInit) {
|
||||||
const pc = peers.get(peerKey(from, streamId))
|
const pc = peers.get(peerKey(from, streamId))
|
||||||
if (!pc) return
|
if (!pc) return
|
||||||
|
rtcLog('ice:remote', { from, streamId, ...iceSummary(candidate) })
|
||||||
try {
|
try {
|
||||||
await pc.addIceCandidate(candidate)
|
await pc.addIceCandidate(candidate)
|
||||||
} catch {
|
} catch (e) {
|
||||||
// ignore late candidates
|
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) {
|
function attachLocalTracksToPeer(remoteId: string, streamId: string) {
|
||||||
const local = options.getLocalStream()
|
const local = options.getLocalStream()
|
||||||
if (!local) return
|
if (!local) return
|
||||||
@@ -132,6 +207,7 @@ export function useWebRTC(options: {
|
|||||||
function bindSocketHandlers() {
|
function bindSocketHandlers() {
|
||||||
const s = options.socket.value
|
const s = options.socket.value
|
||||||
if (!s) return
|
if (!s) return
|
||||||
|
rtcLog('socket:bind webrtc handlers', { clientId: options.clientId.value })
|
||||||
|
|
||||||
s.on('webrtc:offer', async (payload) => {
|
s.on('webrtc:offer', async (payload) => {
|
||||||
if (!payload.from || !payload.sdp || !payload.streamId) return
|
if (!payload.from || !payload.sdp || !payload.streamId) return
|
||||||
@@ -149,6 +225,7 @@ export function useWebRTC(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
s.on('peer:left', (payload) => {
|
s.on('peer:left', (payload) => {
|
||||||
|
rtcLog('peer:left', payload)
|
||||||
for (const key of [...peers.keys()]) {
|
for (const key of [...peers.keys()]) {
|
||||||
if (key.startsWith(`${payload.clientId}:`)) {
|
if (key.startsWith(`${payload.clientId}:`)) {
|
||||||
const streamId = key.split(':')[1]
|
const streamId = key.split(':')[1]
|
||||||
|
|||||||
+12
-2
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ActiveStream, RoomState } from '~/types/room'
|
import type { ActiveStream, RoomState } from '~/types/room'
|
||||||
|
import { rtcLog } from '~/utils/rtcLog'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -218,6 +219,10 @@ async function toggleShare() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function watchStream(stream: ActiveStream) {
|
async function watchStream(stream: ActiveStream) {
|
||||||
|
rtcLog('watch:start local', {
|
||||||
|
streamId: stream.streamId,
|
||||||
|
broadcasterId: stream.broadcasterId,
|
||||||
|
})
|
||||||
await unlockAudio()
|
await unlockAudio()
|
||||||
store.ensureAudioDefaults(stream.streamId)
|
store.ensureAudioDefaults(stream.streamId)
|
||||||
store.setWatching(stream.streamId, true)
|
store.setWatching(stream.streamId, true)
|
||||||
@@ -230,6 +235,10 @@ async function watchStream(stream: ActiveStream) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unwatchStream(stream: ActiveStream) {
|
function unwatchStream(stream: ActiveStream) {
|
||||||
|
rtcLog('watch:stop local', {
|
||||||
|
streamId: stream.streamId,
|
||||||
|
broadcasterId: stream.broadcasterId,
|
||||||
|
})
|
||||||
store.setWatching(stream.streamId, false)
|
store.setWatching(stream.streamId, false)
|
||||||
socket.value?.emit('watch:stop', {
|
socket.value?.emit('watch:stop', {
|
||||||
streamId: stream.streamId,
|
streamId: stream.streamId,
|
||||||
@@ -317,8 +326,8 @@ onBeforeUnmount(() => {
|
|||||||
@unwatch="unwatchStream"
|
@unwatch="unwatchStream"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main class="min-h-0 overflow-auto p-3">
|
<main class="flex min-h-0 flex-col gap-3 overflow-auto p-3 md:overflow-hidden">
|
||||||
<div class="mb-3 md:hidden">
|
<div class="shrink-0 md:hidden">
|
||||||
<RoomParticipantList
|
<RoomParticipantList
|
||||||
:participants="store.participants"
|
:participants="store.participants"
|
||||||
:active-streams="store.activeStreams"
|
:active-streams="store.activeStreams"
|
||||||
@@ -331,6 +340,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RoomStreamGrid
|
<RoomStreamGrid
|
||||||
|
class="min-h-0 flex-1"
|
||||||
:streams="watchingStreams"
|
:streams="watchingStreams"
|
||||||
:remote-streams="remoteStreams"
|
:remote-streams="remoteStreams"
|
||||||
:local-preview="localPreview"
|
:local-preview="localPreview"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
stopStreamsByClient,
|
stopStreamsByClient,
|
||||||
type RoomSession,
|
type RoomSession,
|
||||||
} from '../utils/runtimeRooms'
|
} from '../utils/runtimeRooms'
|
||||||
|
import { rtcLog, rtcWarn } from '../utils/rtcLog'
|
||||||
|
|
||||||
export default defineNitroPlugin((nitroApp: NitroApp) => {
|
export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||||
const engine = new Engine()
|
const engine = new Engine()
|
||||||
@@ -23,13 +24,16 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
|||||||
})
|
})
|
||||||
io.bind(engine)
|
io.bind(engine)
|
||||||
setIO(io)
|
setIO(io)
|
||||||
|
rtcLog('socket.io plugin ready')
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
let currentRoomId: string | null = null
|
let currentRoomId: string | null = null
|
||||||
let currentClientId: string | null = null
|
let currentClientId: string | null = null
|
||||||
|
rtcLog('socket:connect', { socketId: socket.id })
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (!currentRoomId || !currentClientId) return
|
if (!currentRoomId || !currentClientId) return
|
||||||
|
rtcLog('socket:cleanup', { roomId: currentRoomId, clientId: currentClientId })
|
||||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||||
const session = room.sessions.get(currentClientId)
|
const session = room.sessions.get(currentClientId)
|
||||||
if (session && session.socketId === socket.id) {
|
if (session && session.socketId === socket.id) {
|
||||||
@@ -98,12 +102,19 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
|||||||
currentRoomId = roomId
|
currentRoomId = roomId
|
||||||
currentClientId = clientId
|
currentClientId = clientId
|
||||||
socket.join(roomId)
|
socket.join(roomId)
|
||||||
|
rtcLog('room:join', {
|
||||||
|
roomId,
|
||||||
|
clientId,
|
||||||
|
nickname: session.nickname,
|
||||||
|
members: room.sessions.size,
|
||||||
|
})
|
||||||
|
|
||||||
ack?.({ ok: true })
|
ack?.({ ok: true })
|
||||||
socket.emit('room:state', buildRoomState(room))
|
socket.emit('room:state', buildRoomState(room))
|
||||||
broadcastRoomState(roomId)
|
broadcastRoomState(roomId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Join failed'
|
const message = err instanceof Error ? err.message : 'Join failed'
|
||||||
|
rtcWarn('room:join failed', { error: message })
|
||||||
ack?.({ ok: false, error: message })
|
ack?.({ ok: false, error: message })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -141,6 +152,12 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
|||||||
broadcasterNickname: session.nickname,
|
broadcasterNickname: session.nickname,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
})
|
})
|
||||||
|
rtcLog('stream:start', {
|
||||||
|
roomId: currentRoomId,
|
||||||
|
clientId: currentClientId,
|
||||||
|
streamId,
|
||||||
|
streams: room.streams.size,
|
||||||
|
})
|
||||||
ack?.({ ok: true })
|
ack?.({ ok: true })
|
||||||
broadcastRoomState(currentRoomId)
|
broadcastRoomState(currentRoomId)
|
||||||
})
|
})
|
||||||
@@ -156,19 +173,33 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
|||||||
for (const set of room.watchMap.values()) {
|
for (const set of room.watchMap.values()) {
|
||||||
set.delete(streamId)
|
set.delete(streamId)
|
||||||
}
|
}
|
||||||
|
rtcLog('stream:stop', { roomId: currentRoomId, clientId: currentClientId, streamId })
|
||||||
broadcastRoomState(currentRoomId)
|
broadcastRoomState(currentRoomId)
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('watch:start', (payload) => {
|
socket.on('watch:start', (payload) => {
|
||||||
if (!currentRoomId || !currentClientId || !payload?.streamId) return
|
if (!currentRoomId || !currentClientId || !payload?.streamId) return
|
||||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
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)
|
let set = room.watchMap.get(currentClientId)
|
||||||
if (!set) {
|
if (!set) {
|
||||||
set = new Set()
|
set = new Set()
|
||||||
room.watchMap.set(currentClientId, set)
|
room.watchMap.set(currentClientId, set)
|
||||||
}
|
}
|
||||||
set.add(payload.streamId)
|
set.add(payload.streamId)
|
||||||
|
rtcLog('watch:start', {
|
||||||
|
roomId: currentRoomId,
|
||||||
|
viewer: currentClientId,
|
||||||
|
streamId: payload.streamId,
|
||||||
|
broadcasterId: payload.broadcasterId,
|
||||||
|
})
|
||||||
broadcastRoomState(currentRoomId)
|
broadcastRoomState(currentRoomId)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -176,6 +207,11 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
|||||||
if (!currentRoomId || !currentClientId || !payload?.streamId) return
|
if (!currentRoomId || !currentClientId || !payload?.streamId) return
|
||||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||||
room.watchMap.get(currentClientId)?.delete(payload.streamId)
|
room.watchMap.get(currentClientId)?.delete(payload.streamId)
|
||||||
|
rtcLog('watch:stop', {
|
||||||
|
roomId: currentRoomId,
|
||||||
|
viewer: currentClientId,
|
||||||
|
streamId: payload.streamId,
|
||||||
|
})
|
||||||
broadcastRoomState(currentRoomId)
|
broadcastRoomState(currentRoomId)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -184,7 +220,23 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
|||||||
if (!currentRoomId || !currentClientId || !payload?.to) return
|
if (!currentRoomId || !currentClientId || !payload?.to) return
|
||||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||||
const target = room.sessions.get(payload.to)
|
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, {
|
io.to(target.socketId).emit(eventName, {
|
||||||
...payload,
|
...payload,
|
||||||
from: currentClientId,
|
from: currentClientId,
|
||||||
@@ -196,6 +248,7 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
|
|||||||
relay('webrtc:ice')
|
relay('webrtc:ice')
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
|
rtcLog('socket:disconnect', { socketId: socket.id, clientId: currentClientId })
|
||||||
cleanup()
|
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