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
@@ -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', () => {
|
||||
stopShare()
|
||||
})
|
||||
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]
|
||||
|
||||
Reference in new issue
Block a user