feat(capture): switch live sharing source (#3)

This commit is contained in:
AAA authored and GitHub committed 2026-08-18 17:42:44 +08:00
1 parent c04045957a
commit 4fd46ea72a
9 files changed
+518 -47

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Screener is a private, low-latency screen-sharing project for one game player and a small group of friends. Its media path is WebRTC P2P-first, with authenticated TURN fallback for network pairs that cannot connect directly. Viewers should be able to join from a desktop or mobile browser without installing the sender application.
The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and creates an expiring invitation for up to three viewers. Each viewer receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The Node.js service carries only room, authentication, ICE credential, and signaling traffic.
The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and creates an expiring invitation for up to three viewers. The host can change the shared source without replacing healthy peer connections. Each viewer receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The Node.js service carries only room, authentication, ICE credential, and signaling traffic.
This is not yet a production release. Real cross-network TURN behavior, game audio, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix.
+2 -2
View File
@@ -32,8 +32,8 @@ Last updated: 2026-08-18
## Current Implementation
- The repository contains a single npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and a separate coturn deployment.
- The Web PoC implements capture-before-room creation, expiring role tokens, one independent peer connection per viewer, stable signaling reconnect identities, explicit ICE restart or peer rebuild, host generation isolation, short-lived TURN credentials, three manual quality profiles, and local WebRTC statistics.
- Automated checks and same-machine synthetic-media Chromium recovery tests pass, including lost offer/answer, one transient offer failure, signaling-only viewer reconnect, viewer-tab replacement with old-peer cleanup, and cancelled-room cleanup. Real screen/game audio, public TURN/NAT behavior, mobile lifecycle handling, and latency or quality targets remain unverified.
- The Web PoC implements capture-before-room creation, live source replacement without renegotiating healthy peers, expiring role tokens, one independent peer connection per viewer, stable signaling reconnect identities, explicit ICE restart or peer rebuild, host generation isolation, short-lived TURN credentials, three manual quality profiles, and local WebRTC statistics.
- Automated checks and same-machine synthetic-media Chromium recovery tests pass, including lost offer/answer, one transient offer failure, signaling-only viewer reconnect, viewer-tab replacement with old-peer cleanup, cancelled-room cleanup, and video/audio source changes on an existing connection. Real screen/game audio, public TURN/NAT behavior, mobile lifecycle handling, and latency or quality targets remain unverified.
- The next milestone is a staging coturn deployment and the manual matrix in `docs/status.md`, not additional product surface or a native sender.
## Provisional Quality Targets
@@ -138,6 +138,8 @@ Web 版本能直接使用 `getDisplayMedia()` + WebRTC,最适合验证产品
- 1080p60 可以请求但不能保证。选择来源后用 `track.getSettings()` 验证实际分辨率和帧率。
- 最小化窗口、受保护内容、UAC secure desktop 和某些独占全屏路径可能黑屏或停止。游戏使用无边框窗口,或分享整个显示器,通常更稳。
直播中换源不需要先关房间。`RTCRtpSender.replaceTrack()` 可在同类媒体且协商 envelope 允许时替换 sender 的来源而不重新协商;分辨率、帧率块率、音频声道或编码约束不兼容时会拒绝,因此应用必须把失败限制在该 viewer 并重建其连接。为支持浏览器有时返回音频、有时不返回,初始 offer 可以预留 send-only audio transceiver,再将 audio sender 在 `null` 与真实 track 间替换。新的 `getDisplayMedia()` 仍必须由用户手势触发并重新选源,取消选择时旧流应继续工作。
游戏轨建议设置:
```js
@@ -156,6 +158,7 @@ await sender.setParameters(parameters);
来源:
- [W3C Screen Capture](https://www.w3.org/TR/screen-capture/)
- [W3C RTCRtpSender.replaceTrack](https://www.w3.org/TR/webrtc/#dom-rtcrtpsender-replacetrack)
- [MDN getDisplayMedia](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia)
- [MediaStreamTrack content hints](https://www.w3.org/TR/mst-content-hint/)
- [RTCRtpSender 参数](https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters)
@@ -239,6 +242,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件
| --- | --- | --- | --- |
| [MiroTalk BRO](https://github.com/miroslavpejic85/mirotalkbro) | 最贴近一人广播、多名观看;可用部署级开关选择 P2P 或 mediasoup SFU | AGPL-3.0。最快验证概念,闭源前只能研究或另购许可 | [broadcast.js](https://github.com/miroslavpejic85/mirotalkbro/blob/main/public/js/broadcast.js)、[server.js](https://github.com/miroslavpejic85/mirotalkbro/blob/main/app/server.js)、[mediasoup handler](https://github.com/miroslavpejic85/mirotalkbro/blob/main/app/mediasoup-handler.js) |
| [Screego](https://github.com/screego/server) | 极简屏幕分享、P2P fan-out、WebSocket 信令、内置 Pion TURN | GPL-3.0。最适合学习轻控制面和按需 TURN | [useRoom.ts](https://github.com/screego/server/blob/master/ui/src/useRoom.ts)、[ws](https://github.com/screego/server/tree/master/ws)、[turn/server.go](https://github.com/screego/server/blob/master/turn/server.go)、[NAT 文档](https://github.com/screego/server/blob/master/docs/nat-traversal.md) |
| [Tailchat Meeting](https://github.com/msgbyte/tailchat-meeting) | React 捕获生命周期与会议产品交互参考 | Apache-2.0,但媒体基于 mediasoup/SFU,不能作为当前 P2P 拓扑底座 | [ScreenShare.ts](https://github.com/msgbyte/tailchat-meeting/blob/master/app/src/features/ScreenShare.ts)、[media.ts](https://github.com/msgbyte/tailchat-meeting/blob/master/packages/sdk/src/client/media.ts) |
| [WebRTC samples](https://github.com/webrtc/samples) | 官方浏览器 API 最小示例 | BSD 风格。用于理解 API,不是产品框架 | [getDisplayMedia](https://github.com/webrtc/samples/tree/gh-pages/src/content/getusermedia/getdisplaymedia)、[peer connection examples](https://github.com/webrtc/samples/tree/gh-pages/src/content/peerconnection) |
| [PeerJS](https://github.com/peers/peerjs) | 快速 P2P 原型与简单信令抽象 | MIT。原型快,但产品最终可能需要直接控制 RTCPeerConnection 和统计 | [PeerJS server](https://github.com/peers/peerjs-server) |
| [coturn](https://github.com/coturn/coturn) | 生产 STUN/TURN fallback | BSD-3-Clause。P2P-first 必需基础设施 | [turnserver 文档](https://github.com/coturn/coturn/blob/master/README.turnserver)、[Docker](https://github.com/coturn/coturn/blob/master/docker/coturn/README.md) |
@@ -262,6 +266,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件
- coturn 使用 `use-auth-secret` 支持的 TURN REST 短期凭据:`base64(HMAC-SHA1(secret, expiry + ":" + subject))`。coturn 不提供 HTTP 凭据接口,必须由已鉴权的应用服务生成。
- 截至本次复核,coturn 应使用 4.17.2 或更新补丁版本;4.17.2 修复了此前补丁版本的 UDP TTL 回归。
- MiroTalk BRO 当前 P2P 模式仍是 broadcaster 对每位 viewer 建独立连接;Screego 也采用独立 session 和 HMAC TURN 凭据。这验证了拓扑,但两者的静态/长时凭据与轻量恢复策略不直接照搬。
- Screego 的分享生命周期保持简单,当前客户端在分享停止时关闭 peers,未提供 `replaceTrack()` 换源路径,且为 GPL-3.0;本项目只借鉴其边界清晰的生命周期,不复制实现。Tailchat Meeting 使用 Apache-2.0,但其媒体生产者生命周期绑定 mediasoup/SFU;只参考捕获状态,不引入与 ADR-0001 冲突的拓扑。
新增来源,访问日期 2026-08-18:
+4 -3
View File
@@ -4,13 +4,13 @@ Last updated: 2026-08-18
## Phase
The first measurable WebRTC Web proof of concept is implemented. Automated checks and a same-machine Chromium test with a synthetic video source pass. Real display capture, game audio, public-network TURN, mobile lifecycle behavior, and performance targets remain unverified.
The first measurable WebRTC Web proof of concept is implemented. Automated checks and same-machine Chromium tests with synthetic video and audio sources pass, including live source replacement. Real display capture, game audio, public-network TURN, mobile lifecycle behavior, and performance targets remain unverified.
## Established Baseline
- Runtime: Node.js 24, React, TypeScript, Vite, native browser WebRTC, `ws`, Zod, and a separate coturn deployment.
- Product scope: private game sharing for one broadcaster and a hard PoC limit of three viewers.
- Client behavior: screen capture precedes room creation; expiring fragment-token invitations open in desktop or mobile browsers; every viewer has an independent `RTCPeerConnection`.
- Client behavior: screen capture precedes room creation; live source changes preserve healthy peer connections; expiring fragment-token invitations open in desktop or mobile browsers; every viewer has an independent `RTCPeerConnection`.
- Control plane: in-memory rooms, role-bound signaling, stable reconnect identities, short-lived coturn credentials, Origin and payload checks, and explicit room expiry/closure.
- Media topology: direct ICE is preferred per viewer, with authenticated TURN/UDP, TURN/TCP, and TURN/TLS candidates available in production. Mixed direct and relay paths are supported by design.
- Diagnostics: per-peer path, candidate types, ICE protocol, local TURN protocol, RTT, bitrate, frame rate, dimensions, loss, jitter, codec, and quality-limitation fields are read locally from WebRTC stats.
@@ -18,10 +18,11 @@ The first measurable WebRTC Web proof of concept is implemented. Automated check
## Verified In This Revision
- `npm run check` passes type checking, 39 Vitest tests, the client production build, and the server production build.
- `npm run check` passes type checking, 43 Vitest tests, the client production build, and the server production build.
- Headless Chromium 151 created real local peer connections from animated canvas streams. The viewer reached `connected`, received a live video track, and reported a direct UDP path.
- Browser fault injection recovered after dropping the first offer, dropping the first answer, and failing the first `createOffer`. A viewer-only signaling reconnect preserved the existing healthy media peer; replacing that viewer tab rebuilt media, released the old peer, and did not enter a reconnect loop.
- Cancelling a delayed room A and immediately starting room B left B live, stopped only A's capture, and invalidated A's invitation after bounded cleanup.
- Injecting a live picker-cancellation result preserved the old stream. Replacing synthetic video, adding synthetic audio, and removing it all kept the same connected host/viewer peer objects and did not create another offer; retired capture tracks stopped after each successful change.
- Desktop and 390 px responsive layouts were smoke-checked without horizontal overflow.
- Production configuration fails closed when HTTPS, TURN, or sufficiently strong secrets are missing.
- coturn is not installed on this workstation; its configuration has only been checked against upstream documentation and local application tests.
+5 -2
View File
@@ -113,7 +113,7 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate
1. 分享者在开始按钮的同一用户手势内先成功调用 `getDisplayMedia()`。
2. 只有捕获 track 已存在时才请求创建房间、连接 host WebSocket 并显示邀请;若用户在建房响应返回前取消,迟到的房间由独立、限时的鉴权连接立即关闭,且不得覆盖后续分享会话。
3. 观看者认证后,服务端向在线分享者发送 `peer-joined`;host 晚上线或重连时收到完整观看者快照。
4. 分享者创建连接、加入当前屏幕和音频 track,设置 sender 参数,然后创建 offer。
4. 分享者创建连接、加入当前视频 track,并以现有音轨或空 sender 预留 audio transceiver,设置 sender 参数,然后创建 offer。
5. 观看者收到 offer 后创建连接、设置 remote description、创建 answer。
6. 双方持续通过信令服务交换 trickle ICE candidates。
7. remote description 尚未设置时,客户端仅缓存有界数量的 candidates;旧 `connectionId` 消息直接丢弃。
@@ -138,6 +138,8 @@ PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商
`audio: true` 只是请求。若 `getAudioTracks()` 为空,分享者界面必须显示“当前来源没有可共享音频”。纯 Web 首版接受浏览器提供的标签页或系统音频,不承诺仅捕获某个游戏进程。
直播中切换来源仍由明确的按钮手势重新调用 `getDisplayMedia()`,不改变房间、host generation 或媒体拓扑。选源取消或失败时继续发送旧流;成功后为每个现有 sender 调用 `replaceTrack()`,健康连接不重新协商。初始 offer 预留 send-only audio transceiver,使音频可以在 `null -> track -> null` 间切换。若某个 peer 的替换或回滚失败,只重建该 peer;新流提交后才停止旧流,并以当前 stream 身份保护旧 track 的迟到 `ended` 事件。统计码率累加器在换源后重置,避免把不同 track 的计数器混算。
自动全房间降档需要可信的聚合策略,放在测量后的下一阶段;本 PoC 提供手动档位和明确的受限原因,不伪装成已经解决自适应问题。
## TURN 配置与轮换
@@ -232,12 +234,13 @@ credential = base64(HMAC-SHA1(shared_secret, username))
- [ws official README](https://github.com/websockets/ws/blob/master/README.md)
- [Screen Capture specification](https://www.w3.org/TR/screen-capture/)
- [WebRTC specification](https://www.w3.org/TR/webrtc/)
- [RTCRtpSender.replaceTrack](https://www.w3.org/TR/webrtc/#dom-rtcrtpsender-replacetrack)
- [WebRTC Stats](https://www.w3.org/TR/webrtc-stats/)
- [WebRTC official samples](https://webrtc.github.io/samples/)
- [coturn TURN server documentation](https://github.com/coturn/coturn/blob/master/README.turnserver)
- [coturn example configuration](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf)
- [TURN REST API draft implemented by coturn](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00)
MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。项目许可证确定前不复制其代码。
MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。Tailchat Meeting(Apache-2.0)用于捕获生命周期对照,但它采用 mediasoup/SFU,不作为本项目拓扑或依赖底座。项目许可证确定前不复制 GPL/AGPL 代码。
---
+202 -27
View File
@@ -3,6 +3,7 @@ import {
Copy,
KeyRound,
MonitorUp,
RefreshCw,
Square,
Users,
} from "lucide-react";
@@ -71,10 +72,10 @@ function closeAbandonedRoom(room: CreateRoomResponse): void {
clientId: getStableClientId("host", room.roomId),
},
{
onMessage: () => undefined,
onStatus: () => undefined,
onProtocolError: () => undefined,
onTerminated: () => undefined,
onMessage: () => undefined,
onStatus: () => undefined,
onProtocolError: () => undefined,
onTerminated: () => undefined,
},
);
signal.start();
@@ -103,6 +104,7 @@ export function HostPage() {
);
const [notice, setNotice] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [switchingSource, setSwitchingSource] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
@@ -111,6 +113,8 @@ export function HostPage() {
const peersRef = useRef(new Map<string, HostPeer>());
const generationRef = useRef(0);
const activeGenerationRef = useRef<number | null>(null);
const sourceSwitchRef = useRef<object | null>(null);
const retiringStreamRef = useRef<MediaStream | null>(null);
const viewers = useMemo(
() => Array.from(peerSnapshots.values()),
@@ -127,11 +131,14 @@ export function HostPage() {
() => () => {
activeGenerationRef.current = null;
generationRef.current += 1;
sourceSwitchRef.current = null;
signalRef.current?.stop();
peersRef.current.forEach((peer) => peer.dispose());
peersRef.current.clear();
streamRef.current?.getTracks().forEach((track) => track.stop());
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
retiringStreamRef.current = null;
},
[],
);
@@ -144,6 +151,7 @@ export function HostPage() {
}
function disposeResources(notifyServer: boolean): void {
sourceSwitchRef.current = null;
const signal = signalRef.current;
if (signal) {
if (notifyServer) {
@@ -156,12 +164,15 @@ export function HostPage() {
peersRef.current.forEach((peer) => peer.dispose());
peersRef.current.clear();
streamRef.current?.getTracks().forEach((track) => track.stop());
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
retiringStreamRef.current = null;
iceConfigRef.current = null;
setStream(null);
setRoom(null);
setPeerSnapshots(new Map());
setSignalStatus("offline");
setSwitchingSource(false);
}
function endSharing(message: string, notifyServer = true): void {
@@ -184,6 +195,32 @@ export function HostPage() {
});
}
function watchCaptureEnd(
captured: MediaStream,
generation: number,
): void {
captured.getVideoTracks()[0]?.addEventListener(
"ended",
() => {
if (
isCurrentGeneration(generation) &&
streamRef.current === captured
) {
endSharing("屏幕分享已结束");
}
},
{ once: true },
);
}
function finishSourceSwitch(token: object): void {
if (sourceSwitchRef.current !== token) {
return;
}
sourceSwitchRef.current = null;
setSwitchingSource(false);
}
function removePeer(peerId: string): void {
peersRef.current.get(peerId)?.dispose();
peersRef.current.delete(peerId);
@@ -420,15 +457,7 @@ export function HostPage() {
streamRef.current = captured;
setStream(captured);
setDetails(captureDetails(captured));
captured.getVideoTracks()[0]?.addEventListener(
"ended",
() => {
if (isCurrentGeneration(generation)) {
endSharing("屏幕分享已结束");
}
},
{ once: true },
);
watchCaptureEnd(captured, generation);
let createdRoom: CreateRoomResponse | null = null;
try {
@@ -514,6 +543,139 @@ export function HostPage() {
}
}
async function switchSource(): Promise<void> {
const generation = activeGenerationRef.current;
if (
phase !== "live" ||
generation === null ||
!isCurrentGeneration(generation) ||
sourceSwitchRef.current
) {
return;
}
const token = {};
sourceSwitchRef.current = token;
setSwitchingSource(true);
setNotice(null);
let captured: MediaStream;
try {
// Like initial capture, changing source must begin in this button gesture.
captured = await captureDisplay(QUALITY_PROFILES[qualityId]);
} catch (error) {
if (
isCurrentGeneration(generation) &&
sourceSwitchRef.current === token
) {
setNotice(readableError(error));
}
finishSourceSwitch(token);
return;
}
if (
!isCurrentGeneration(generation) ||
sourceSwitchRef.current !== token
) {
captured.getTracks().forEach((track) => track.stop());
return;
}
const previousStream = streamRef.current;
if (!previousStream) {
captured.getTracks().forEach((track) => track.stop());
setNotice("当前分享已经结束");
finishSourceSwitch(token);
return;
}
retiringStreamRef.current = previousStream;
streamRef.current = captured;
setStream(captured);
setDetails(captureDetails(captured));
watchCaptureEnd(captured, generation);
try {
const replacements = await Promise.all(
[...peersRef.current.entries()].map(async ([peerId, peer]) => {
try {
return {
peerId,
peer,
replaced: await peer.replaceStream(
captured,
QUALITY_PROFILES[qualityId],
),
};
} catch {
return { peerId, peer, replaced: false };
}
}),
);
if (
!isCurrentGeneration(generation) ||
sourceSwitchRef.current !== token
) {
captured.getTracks().forEach((track) => track.stop());
return;
}
const failedPeerIds: string[] = [];
for (const { peerId, peer, replaced } of replacements) {
if (!replaced && peersRef.current.get(peerId) === peer) {
removePeer(peerId);
failedPeerIds.push(peerId);
}
}
previousStream.getTracks().forEach((track) => track.stop());
if (retiringStreamRef.current === previousStream) {
retiringStreamRef.current = null;
}
await Promise.all(
failedPeerIds.map(async (peerId) => {
if (
!isCurrentGeneration(generation) ||
sourceSwitchRef.current !== token ||
peersRef.current.has(peerId)
) {
return;
}
try {
await startPeer(peerId, generation);
} catch (error) {
if (
isCurrentGeneration(generation) &&
sourceSwitchRef.current === token
) {
setNotice(readableError(error));
}
}
}),
);
if (
isCurrentGeneration(generation) &&
sourceSwitchRef.current === token
) {
setNotice(
failedPeerIds.length > 0
? "分享来源已切换,部分观看者正在重新连接"
: "分享来源已切换",
);
}
} finally {
previousStream.getTracks().forEach((track) => track.stop());
if (retiringStreamRef.current === previousStream) {
retiringStreamRef.current = null;
}
finishSourceSwitch(token);
}
}
async function copyInvite(): Promise<void> {
if (!room) {
return;
@@ -566,18 +728,31 @@ export function HostPage() {
</div>
{forceRelay && <span className="diagnostic-badge">强制中继</span>}
{(phase === "live" || phase === "starting") && (
<button
className="button button-danger"
type="button"
onClick={() =>
endSharing(
phase === "starting" ? "启动已取消" : "屏幕分享已结束",
)
}
>
<Square size={16} fill="currentColor" aria-hidden="true" />
{phase === "starting" ? "取消" : "停止分享"}
</button>
<div className="broadcast-actions">
{phase === "live" && (
<button
className="button button-secondary"
type="button"
disabled={switchingSource}
onClick={() => void switchSource()}
>
<RefreshCw size={16} aria-hidden="true" />
{switchingSource ? "正在选择" : "切换来源"}
</button>
)}
<button
className="button button-danger"
type="button"
onClick={() =>
endSharing(
phase === "starting" ? "启动已取消" : "屏幕分享已结束",
)
}
>
<Square size={16} fill="currentColor" aria-hidden="true" />
{phase === "starting" ? "取消" : "停止分享"}
</button>
</div>
)}
</div>
@@ -590,9 +765,9 @@ export function HostPage() {
<span>{phase === "ended" ? "分享已结束" : "未分享画面"}</span>
</div>
)}
{phase === "starting" && (
{(phase === "starting" || switchingSource) && (
<div className="stage-overlay" role="status">
正在连接
{switchingSource ? "正在切换来源" : "正在连接"}
</div>
)}
</div>
+22 -1
View File
@@ -165,6 +165,12 @@ h3 {
line-height: 20px;
}
.broadcast-actions {
display: flex;
gap: 8px;
align-items: center;
}
.video-stage {
position: relative;
width: 100%;
@@ -359,6 +365,16 @@ h3 {
background: #59df91;
}
.button-secondary {
border-color: var(--border-strong);
color: var(--text);
background: var(--surface-raised);
}
.button-secondary:hover:not(:disabled) {
background: var(--surface-hover);
}
.button-danger {
border-color: #7c3d3d;
color: #ffaaaa;
@@ -705,10 +721,15 @@ h3 {
flex-wrap: wrap;
}
.section-heading .button-danger {
.broadcast-actions {
width: 100%;
}
.broadcast-actions .button {
min-width: 0;
flex: 1;
}
.viewer-badges {
width: 100%;
justify-content: flex-start;
+70 -11
View File
@@ -27,6 +27,8 @@ export class HostPeer {
private readonly connection: RTCPeerConnection;
private readonly pendingCandidates: SignalCandidate[] = [];
private readonly statsAccumulator = createStatsAccumulator();
private videoSender: RTCRtpSender | null = null;
private audioSender: RTCRtpSender | null = null;
private statsTimer: number | null = null;
private disposed = false;
private negotiating = false;
@@ -35,8 +37,8 @@ export class HostPeer {
constructor(
readonly peerId: string,
iceConfig: IceConfig,
private readonly stream: MediaStream,
private readonly profile: QualityProfile,
private stream: MediaStream,
private profile: QualityProfile,
private readonly events: HostPeerEvents,
private readonly forceRelay = false,
) {
@@ -56,15 +58,24 @@ export class HostPeer {
}
async start(): Promise<boolean> {
for (const track of this.stream.getTracks()) {
const sender = this.connection.addTrack(track, this.stream);
if (track.kind === "video") {
try {
await configureVideoSender(sender, this.profile);
} catch (error) {
console.warn("Browser rejected preferred sender parameters", error);
}
}
const videoTrack = this.stream.getVideoTracks()[0];
if (!videoTrack) {
this.setError(new Error("共享流缺少视频轨道"), "创建连接失败");
return false;
}
const audioTrack = this.stream.getAudioTracks()[0] ?? null;
this.videoSender = this.connection.addTransceiver(videoTrack, {
direction: "sendonly",
streams: [this.stream],
}).sender;
this.audioSender = this.connection.addTransceiver(audioTrack ?? "audio", {
direction: "sendonly",
streams: [this.stream],
}).sender;
try {
await configureVideoSender(this.videoSender, this.profile);
} catch (error) {
console.warn("Browser rejected preferred sender parameters", error);
}
if (!(await this.createOffer(false)) || this.disposed) {
return false;
@@ -76,6 +87,54 @@ export class HostPeer {
return true;
}
async replaceStream(
nextStream: MediaStream,
profile: QualityProfile,
): Promise<boolean> {
const nextVideoTrack = nextStream.getVideoTracks()[0];
if (
this.disposed ||
!this.videoSender ||
!this.audioSender ||
!nextVideoTrack
) {
return false;
}
const nextAudioTrack = nextStream.getAudioTracks()[0] ?? null;
const previousVideoTrack = this.videoSender.track;
const previousAudioTrack = this.audioSender.track;
try {
await this.videoSender.replaceTrack(nextVideoTrack);
await this.audioSender.replaceTrack(nextAudioTrack);
} catch (error) {
await Promise.allSettled([
this.videoSender.replaceTrack(previousVideoTrack),
this.audioSender.replaceTrack(previousAudioTrack),
]);
this.setError(error, "切换共享源失败");
return false;
}
if (this.disposed) {
return false;
}
this.stream = nextStream;
this.profile = profile;
this.statsAccumulator.bytes = null;
this.statsAccumulator.frames = null;
this.statsAccumulator.timestamp = null;
try {
await configureVideoSender(this.videoSender, this.profile);
} catch (error) {
console.warn("Browser rejected preferred sender parameters", error);
}
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return true;
}
async acceptSignal(payload: SignalPayload): Promise<void> {
if (this.disposed || payload.connectionId !== this.connectionId) {
return;
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QUALITY_PROFILES } from "../src/client/media/quality.ts";
import { HostPeer } from "../src/client/webrtc/host-peer.ts";
class FakeSender {
failNextReplace = false;
readonly setParameters = vi.fn(async () => undefined);
readonly replaceTrack = vi.fn(async (track: MediaStreamTrack | null) => {
if (this.failNextReplace) {
this.failNextReplace = false;
throw new Error("replaceTrack failed");
}
this.track = track;
});
constructor(public track: MediaStreamTrack | null) {}
getParameters(): RTCRtpSendParameters {
return { encodings: [{}] } as RTCRtpSendParameters;
}
}
class FakePeerConnection {
static latest: FakePeerConnection | null = null;
readonly senders: FakeSender[] = [];
readonly transceiverInputs: Array<{
trackOrKind: MediaStreamTrack | string;
init?: RTCRtpTransceiverInit;
}> = [];
connectionState: RTCPeerConnectionState = "new";
iceConnectionState: RTCIceConnectionState = "new";
signalingState: RTCSignalingState = "stable";
localDescription: RTCSessionDescription | null = null;
remoteDescription: RTCSessionDescription | null = null;
constructor() {
FakePeerConnection.latest = this;
}
addTransceiver(
trackOrKind: MediaStreamTrack | string,
init?: RTCRtpTransceiverInit,
): RTCRtpTransceiver {
const sender = new FakeSender(
typeof trackOrKind === "string" ? null : trackOrKind,
);
this.senders.push(sender);
this.transceiverInputs.push({ trackOrKind, init });
return { sender } as unknown as RTCRtpTransceiver;
}
addEventListener(): void {}
async createOffer(): Promise<RTCSessionDescriptionInit> {
return { type: "offer", sdp: "test-offer" };
}
async setLocalDescription(
description: RTCSessionDescriptionInit,
): Promise<void> {
this.localDescription = description as RTCSessionDescription;
}
close(): void {
this.connectionState = "closed";
}
}
function createTrack(kind: "video" | "audio", id: string): MediaStreamTrack {
return { id, kind } as MediaStreamTrack;
}
function createStream(
videoTrack: MediaStreamTrack,
audioTrack: MediaStreamTrack | null,
): MediaStream {
const tracks = audioTrack ? [videoTrack, audioTrack] : [videoTrack];
return {
getTracks: () => tracks,
getVideoTracks: () => [videoTrack],
getAudioTracks: () => (audioTrack ? [audioTrack] : []),
} as unknown as MediaStream;
}
function createPeer(stream: MediaStream): HostPeer {
return new HostPeer(
"viewer-peer",
{ iceServers: [], expiresAt: null, relayAvailable: false },
stream,
QUALITY_PROFILES["720p30"],
{
sendSignal: () => true,
onUpdate: () => undefined,
},
);
}
beforeEach(() => {
FakePeerConnection.latest = null;
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
vi.stubGlobal("window", {
setInterval: vi.fn(() => 1),
clearInterval: vi.fn(),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("HostPeer source replacement", () => {
it("reserves send-only video and audio senders and replaces both tracks", async () => {
const oldVideo = createTrack("video", "old-video");
const oldAudio = createTrack("audio", "old-audio");
const peer = createPeer(createStream(oldVideo, oldAudio));
await expect(peer.start()).resolves.toBe(true);
const connection = FakePeerConnection.latest!;
expect(connection.transceiverInputs).toHaveLength(2);
expect(connection.transceiverInputs.map(({ init }) => init?.direction)).toEqual([
"sendonly",
"sendonly",
]);
const nextVideo = createTrack("video", "next-video");
const nextAudio = createTrack("audio", "next-audio");
await expect(
peer.replaceStream(
createStream(nextVideo, nextAudio),
QUALITY_PROFILES["1080p60"],
),
).resolves.toBe(true);
expect(connection.senders[0]?.track).toBe(nextVideo);
expect(connection.senders[1]?.track).toBe(nextAudio);
expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(2);
});
it("fills a pre-negotiated audio sender that started without a track", async () => {
const peer = createPeer(
createStream(createTrack("video", "old-video"), null),
);
await expect(peer.start()).resolves.toBe(true);
const connection = FakePeerConnection.latest!;
expect(connection.transceiverInputs[1]?.trackOrKind).toBe("audio");
expect(connection.senders[1]?.track).toBeNull();
const nextAudio = createTrack("audio", "next-audio");
await expect(
peer.replaceStream(
createStream(createTrack("video", "next-video"), nextAudio),
QUALITY_PROFILES["720p60"],
),
).resolves.toBe(true);
expect(connection.senders[1]?.track).toBe(nextAudio);
});
it("stops sending audio without renegotiating when the new source has none", async () => {
const peer = createPeer(
createStream(
createTrack("video", "old-video"),
createTrack("audio", "old-audio"),
),
);
await expect(peer.start()).resolves.toBe(true);
const connection = FakePeerConnection.latest!;
await expect(
peer.replaceStream(
createStream(createTrack("video", "next-video"), null),
QUALITY_PROFILES["720p30"],
),
).resolves.toBe(true);
expect(connection.senders[1]?.track).toBeNull();
});
it("rolls the first sender back when the second replacement fails", async () => {
const oldVideo = createTrack("video", "old-video");
const oldAudio = createTrack("audio", "old-audio");
const peer = createPeer(createStream(oldVideo, oldAudio));
await expect(peer.start()).resolves.toBe(true);
const connection = FakePeerConnection.latest!;
const videoSender = connection.senders[0]!;
const audioSender = connection.senders[1]!;
audioSender.failNextReplace = true;
const nextVideo = createTrack("video", "next-video");
const nextAudio = createTrack("audio", "next-audio");
await expect(
peer.replaceStream(
createStream(nextVideo, nextAudio),
QUALITY_PROFILES["1080p60"],
),
).resolves.toBe(false);
expect(videoSender.replaceTrack).toHaveBeenNthCalledWith(1, nextVideo);
expect(videoSender.replaceTrack).toHaveBeenNthCalledWith(2, oldVideo);
expect(audioSender.replaceTrack).toHaveBeenNthCalledWith(1, nextAudio);
expect(audioSender.replaceTrack).toHaveBeenNthCalledWith(2, oldAudio);
expect(videoSender.track).toBe(oldVideo);
expect(audioSender.track).toBe(oldAudio);
});
});