fix(media): preserve Host capture across media recovery (#279)

This commit is contained in:
AAA authored and GitHub committed 2026-08-26 23:08:29 +08:00
1 parent 4a919d9b65
commit 06c154a080
10 files changed
+23 -40

No files matched your search

+1
View File
@@ -4,6 +4,7 @@
- Every task and action in this repository must follow the complete repo-tracked [`stop-that-shit` skill](./.agents/skills/stop-that-shit/SKILL.md). Read it before work; that file is the Screener authority for generic task boundaries, and any installed copy must stay synchronized with it rather than becoming a divergent variant.
- Core summary: within higher-priority instructions, the latest explicit user decision defines the task; take only requested actions and proven necessary consequences, and do not treat review/research/implementation as authority for implementation/deployment/cleanup beyond its task type. Broad words such as improve or optimize add no unnamed scope. Prefer standards and one owning invariant over example-specific mechanisms, while preserving unrelated work and necessary callers/tests/security. Reconstruct each deliverable from the accepted current result so it stands alone without rejected alternatives, process narration, or modification traces; inspect only the pending changes and report the result with completion evidence. The full skill governs when this summary is incomplete.
- Model recovery and failure handling from the smallest underlying authority and connection state. A concrete cause such as a graceful restart, crash, timeout, or network loss may improve diagnostics only when it changes a reachable decision; it must not create a parallel state, retry loop, fallback, or user-facing branch when the required recovery action is the same.
- Before a material product-contract, route-model, security, infrastructure, or irreversible change, settle the meaning against the whole product model and evidence, challenge contradictions or needless complexity, and update its single owning truth document. An ordinary scoped implementation or bug fix does not require a truth checkpoint unless it actually changes durable semantics or a current production/execution snapshot.
- Until the owner explicitly declares a public release, maintain only the current internal product contract. Do not preserve backward or forward compatibility, legacy wire/config aliases, dual parsers or writers, translators, deprecated APIs, migration shims, or tests whose only consumer is an older release. Upgrade the private deployment atomically, reject stale clients before authority, delete replaced surfaces, and use Git history for rollback. Reassess compatibility only when public release requirements are explicitly opened.
- Use the lean path by default for ordinary scoped work: inspect the affected code and owner, make the smallest change, run focused checks, inspect the diff, stage the requested files, and report. Do not automatically expand a small change into broad research, multiple truth-document edits, a full test suite, independent reviews, PR/merge, deployment, worktree cleanup, or a release postflight. Use the full integration/release path only when the owner explicitly asks for it or when closing a major phase or changing a public contract, route/security model, infrastructure, persistent/irreversible state, or release boundary.
-1
View File
@@ -28,7 +28,6 @@ export function SignalStatusBadge({
const values: Record<SignalConnectionState, BadgeProps> = {
connecting: { tone: "neutral", label: "正在连接" },
connected: { tone: "good", label: "服务器已连接" },
restarting: { tone: "warning", label: "服务已重启,正在恢复" },
reconnecting: { tone: "warning", label: "正在恢复" },
offline: { tone: "neutral", label: "服务器未连接" },
};
+3 -9
View File
@@ -284,11 +284,7 @@ export class SignalingClient {
this.clearSignalingWatchdog();
this.clearAuthenticationTimer();
if (!this.stopped && shouldReconnectSignaling(event.code)) {
this.scheduleReconnect(
event.code === SIGNAL_CLOSE_CODES.serviceRestart
? "restarting"
: "reconnecting",
);
this.scheduleReconnect();
} else if (!this.stopped) {
this.stopped = true;
this.clearTimers();
@@ -308,10 +304,8 @@ export class SignalingClient {
});
}
private scheduleReconnect(
status: "restarting" | "reconnecting" = "reconnecting",
): void {
this.events.onStatus(status);
private scheduleReconnect(): void {
this.events.onStatus("reconnecting");
const exponent = Math.min(this.reconnectAttempt, 4);
const delay = Math.min(8_000, 500 * 2 ** exponent) + Math.random() * 250;
this.reconnectAttempt += 1;
+3 -11
View File
@@ -527,7 +527,6 @@ export function deriveViewerPresentation(
if (currentFrame) {
const signalRecovering =
state.signal === "restarting" ||
state.signal === "reconnecting" ||
state.signal === "connecting";
const mediaRecovering =
@@ -535,20 +534,14 @@ export function deriveViewerPresentation(
return {
...presentation(
signalRecovering || mediaRecovering ? "recovering" : "playing",
state.signal === "restarting"
? "服务已重启,正在恢复"
: signalRecovering || mediaRecovering
? "正在恢复连接"
: "正在播放",
signalRecovering || mediaRecovering ? "正在恢复连接" : "正在播放",
"none",
state,
),
notice: state.host === "offline"
? "分享者连接已中断,画面可能冻结"
: signalRecovering
? state.signal === "restarting"
? "服务已重启,正在恢复媒体"
: "服务器连接正在恢复,画面仍在播放"
? "服务器连接正在恢复,画面仍在播放"
: mediaRecovering
? "媒体连接正在恢复"
: null,
@@ -639,14 +632,13 @@ export function deriveViewerPresentation(
);
}
if (
state.signal === "restarting" ||
state.signal === "reconnecting" ||
state.connection === "reconnecting" ||
state.connection === "failed"
) {
return presentation(
"recovering",
state.signal === "restarting" ? "服务已重启,正在恢复" : "正在恢复连接",
"正在恢复连接",
frameOverlay,
state,
);
-8
View File
@@ -144,7 +144,6 @@ import {
} from "./host-page-notices";
type HostPhase = "idle" | "starting" | "live" | "ended" | "error";
const SERVER_RESTART_NOTICE = "服务已重启,正在恢复";
type ViewerQualityEvidence = Extract<
ServerMessage,
@@ -1628,13 +1627,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
signalRef.current === signal
) {
setSignalStatus(status);
if (status === "restarting") {
setNotice(SERVER_RESTART_NOTICE);
} else if (status === "connected") {
setNotice((current) =>
current === SERVER_RESTART_NOTICE ? null : current,
);
}
}
},
onTerminated: (reason) => {
+4 -1
View File
@@ -130,7 +130,10 @@ export class SfuPublisher {
return false;
}
const room = new sdk.Room({ dynacast: true });
const room = new sdk.Room({
dynacast: true,
stopLocalTrackOnUnpublish: false,
});
this.room = room;
this.sdk = sdk;
room.on(sdk.RoomEvent.Disconnected, () => {
-1
View File
@@ -1,7 +1,6 @@
export type SignalConnectionState =
| "connecting"
| "connected"
| "restarting"
| "reconnecting"
| "offline";
+2 -2
View File
@@ -742,7 +742,7 @@ describe("client signaling recovery policy", () => {
expect(shouldReconnectSignaling(1006)).toBe(true);
});
it("reports a service restart while scheduling reconnect", () => {
it("uses the common reconnect state for a recoverable close", () => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
@@ -784,7 +784,7 @@ describe("client signaling recovery policy", () => {
Object.defineProperty(closed, "code", { value: 1012 });
sockets[0]!.dispatchEvent(closed);
expect(statuses.at(-1)).toBe("restarting");
expect(statuses.at(-1)).toBe("reconnecting");
signal.stop();
});
+4 -1
View File
@@ -659,7 +659,10 @@ describe("SfuPublisher", () => {
await expect(publisher.connect(connection)).resolves.toBe(true);
const room = livekit.state.rooms[0];
expect(room.options).toEqual({ dynacast: true });
expect(room.options).toEqual({
dynacast: true,
stopLocalTrackOnUnpublish: false,
});
expect(room.connect).toHaveBeenCalledWith(connection.url, connection.token, {
autoSubscribe: false,
rtcConfig: { iceServers: [] },
+6 -6
View File
@@ -350,22 +350,22 @@ describe("Viewer presentation reducer", () => {
});
});
it("identifies a service restart while retaining the current frame", () => {
const restarting = apply(
it("retains the current frame while signaling reconnects", () => {
const reconnecting = apply(
{ type: "access", access: "ready" },
{ type: "signal", signal: "connected" },
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, revision: 3 },
{ type: "signal", signal: "restarting" },
{ type: "signal", signal: "reconnecting" },
);
expect(deriveViewerPresentation(restarting)).toMatchObject({
expect(deriveViewerPresentation(reconnecting)).toMatchObject({
stage: "recovering",
message: "服务已重启,正在恢复",
message: "正在恢复连接",
overlay: "none",
notice: "服务已重启,正在恢复媒体",
notice: "服务器连接正在恢复,画面仍在播放",
});
});