- {!details?.hasSourceAudio && stream ? (
+ {details?.hasSourceAudio === false && stream ? (
) : null}
{hostSfuWarningText ? (
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx
index 408f5a2..bdbe3c3 100644
--- a/src/client/pages/ViewerPage.tsx
+++ b/src/client/pages/ViewerPage.tsx
@@ -128,6 +128,7 @@ interface ViewerPageProps {
viewerGrant?: string;
invalidGrant?: boolean;
launchedByClient?: boolean;
+ connectionAttemptProgress4?: boolean;
}
type ViewerQualityEvidence = Extract<
@@ -167,6 +168,7 @@ export function ViewerPage({
viewerGrant,
invalidGrant = false,
launchedByClient = false,
+ connectionAttemptProgress4 = false,
}: ViewerPageProps) {
const { lang, t, vis, titleFrames } = useCopy();
const viewerClientId = useMemo(
@@ -594,6 +596,7 @@ export function ViewerPage({
: {}),
displayName: displayNameRef.current,
viewerPresence: true,
+ ...(connectionAttemptProgress4 ? { connectionAttemptProgress4: true } : {}),
},
{
onStatus: (status) => {
@@ -1583,7 +1586,7 @@ export function ViewerPage({
? "paused"
: message.hostOnline
? "online"
- : "stopped",
+ : "unknown",
});
currentQualitySettings = message.qualitySettings;
currentShareGeneration = message.shareGeneration;
@@ -1936,7 +1939,7 @@ export function ViewerPage({
viewerRelaySourceKey = null;
void nativeClientPromise?.then((client) => client?.close());
};
- }, [roomId, viewerGrant, viewerPasswordAttempt, launchedByClient]);
+ }, [roomId, viewerGrant, viewerPasswordAttempt, launchedByClient, connectionAttemptProgress4]);
useEffect(() => {
const video = videoRef.current;
diff --git a/src/client/pages/status-preview-scenarios.ts b/src/client/pages/status-preview-scenarios.ts
index 51cd5ea..73501f3 100644
--- a/src/client/pages/status-preview-scenarios.ts
+++ b/src/client/pages/status-preview-scenarios.ts
@@ -62,6 +62,16 @@ export const STATUS_SCENARIOS: readonly StatusScenario[] = [
scenario("signal-recovering", "信令断开,画面仍在播放", "控制连接正在恢复;媒体仍有当前画面,标题保留观看状态。", [
...playing, { type: "signal", signal: "reconnecting" },
]),
+ scenario("signal-media-recovering", "信令与媒体同时恢复", "服务器提示与媒体状态各自描述自己的事实;服务器提醒不把恢复中的媒体显示成正常播放。", [
+ ...playing,
+ { type: "signal", signal: "reconnecting" },
+ { type: "connection", revision: 1, connection: "reconnecting" },
+ ]),
+ scenario("host-offline-media-recovering", "房主离线,媒体正在恢复", "保留已证明的画面;电视说明媒体正在恢复,旁边的提醒说明房主连接中断。", [
+ ...playing,
+ { type: "host", host: "offline" },
+ { type: "connection", revision: 1, connection: "reconnecting" },
+ ]),
scenario("host-offline-playing", "房主离线,仍有当前画面", "房主在线状态和正在接收的媒体不是同一件事;先给提示,不直接覆盖画面。", [
...playing, { type: "host", host: "offline" },
]),
@@ -95,10 +105,28 @@ export const STATUS_SCENARIOS: readonly StatusScenario[] = [
...receiving, { type: "playback-failed", generation: 1, revision: 1 },
]),
scenario("waiting-host", "等待房主开始分享", "房间存在,当前没有共享源。", [{ type: "access", access: "ready" }]),
+ scenario("host-absence-snapshot", "重连后只确认房主不在线", "快照没有说明房主为何不在线;用中性等待,不推断主动停播或连接中断。", [
+ ...ready, { type: "host", host: "unknown" },
+ ]),
scenario("host-offline", "房主离线,没有画面", "没有当前或留存画面,说明房主离线。", [
...ready, { type: "host", host: "offline" },
]),
+ scenario("host-offline-route-failed", "房主离线,线路也已失败", "没有可播放画面时先说明房主离线;线路失败证据仍保留,房主回来不代表线路已经恢复。", [
+ ...receiving,
+ { type: "host", host: "offline" },
+ { type: "route-status", revision: 1, state: "failed" },
+ ]),
+ scenario("host-offline-retained", "房主离线,只剩最后画面", "线路失败使当前播放证明失效;保留最后画面,以轻量覆盖层提示房主离线。", [
+ ...playing,
+ { type: "route-status", revision: 1, state: "failed" },
+ { type: "host", host: "offline" },
+ ]),
scenario("stopped", "分享已经停止", "正常结束,不伪装为正在恢复或播放失败。", [...playing, { type: "sharing-stopped" }]),
+ scenario("stopped-route-failed", "停止分享后收到线路失败", "停止分享清理画面;随后到达的线路失败不把正常停播变成媒体故障。", [
+ ...playing,
+ { type: "sharing-stopped" },
+ { type: "route-status", revision: 1, state: "failed" },
+ ]),
...denied.map(([failure, name]) => scenario(failure.toLowerCase(), name,
"服务端准入或会话终止事实优先;旧画面和旧质量样本不能重新覆盖这个状态。",
[{ type: "access", access: "denied", failure }])),
diff --git a/src/client/styles.css b/src/client/styles.css
index 814a013..a6a7b23 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -308,7 +308,7 @@ body.lr-theater-open .lr-top { visibility: hidden; }
background: var(--stage);
}
.lr-tv-screen video { display: block; width: 100%; height: 100%; object-fit: contain; background: var(--stage); }
-.lr-tv-screen.has-entry { aspect-ratio: 16 / 9; }
+.lr-tv-screen.has-entry { display: grid; aspect-ratio: 16 / 9; }
.lr-tv-screen.is-on { animation: lr-crt .5s cubic-bezier(.2, .8, .3, 1) both; }
.lr-tv-chin {
display: flex;
@@ -593,6 +593,7 @@ body.lr-theater-open .lr-top { visibility: hidden; }
background: rgba(255, 255, 255, .1);
}
.lr-source-option-preview {
+ grid-template: minmax(0, 1fr) / minmax(0, 1fr);
width: 100%;
height: 144px;
overflow: hidden;
diff --git a/src/client/ui/media-status.ts b/src/client/ui/media-status.ts
index 24b16a3..b31be00 100644
--- a/src/client/ui/media-status.ts
+++ b/src/client/ui/media-status.ts
@@ -127,14 +127,10 @@ export function deriveViewerStatus(
signal: SignalConnectionState,
route: "p2p" | "sfu" | null = null,
) {
- // Control recovery does not interrupt a picture that remains proved.
- const mediaRecovering = presentation.noticeKey === "viewer.notice.mediaRecovering";
- const playable = presentation.overlay === "none" && !mediaRecovering;
- const stage = playable ? "playing" : presentation.stage;
+ const stage = presentation.stage;
const activity: StatusDescriptor = {
...STAGE_VISUALS[stage],
- labelKey: mediaRecovering ? "viewer.notice.mediaRecovering"
- : playable ? "viewer.msg.playing" : presentation.messageKey,
+ labelKey: presentation.messageKey,
...(stage === "receiving" ? { comic: route === "sfu" ? "connecting-sfu" as const : "connecting-p2p" as const } : {}),
};
let notice: (StatusDescriptor & { comic: ComicKind }) | null = null;
diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts
index 3bf0afd..2f94ac0 100644
--- a/src/shared/protocol.ts
+++ b/src/shared/protocol.ts
@@ -218,6 +218,7 @@ export const DEFAULT_ROUTE_POLICY = {
} as const satisfies RoutePolicy;
export const runtimeCapabilitiesSchema = z.object({
+ connectionAttemptProgress4: z.boolean().optional(),
sfu: z.boolean().default(false),
natPrediction: z.boolean().default(false),
});
@@ -372,9 +373,9 @@ export const preparedRouteCandidateSchema = z
transport: z.enum(["direct", "sfu"]),
qualityProbe: z.boolean(),
connectionAttempt: z.object({
- current: z.number().int().min(1).max(3),
- total: z.literal(3),
- }).strict().optional(),
+ current: z.number().int().min(1).max(4),
+ total: z.union([z.literal(3), z.literal(4)]),
+ }).strict().refine(({ current, total }) => current <= total).optional(),
})
.strict();
export type PreparedRouteCandidate = z.infer<
@@ -816,6 +817,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
qualitySettings: qualitySettingsSchema.optional(),
routePolicy: routePolicySchema.default(DEFAULT_ROUTE_POLICY),
viewerPresence: z.literal(true).optional(),
+ connectionAttemptProgress4: z.literal(true).optional(),
displayName: displayNameSchema.optional(),
})
.strict(),
@@ -830,6 +832,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
viewerPassword: viewerPasswordSchema.optional(),
displayName: displayNameSchema.optional(),
viewerPresence: z.literal(true).optional(),
+ connectionAttemptProgress4: z.literal(true).optional(),
})
.strict(),
]);
diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts
index 6691b3b..66b035a 100644
--- a/tests/client-state.test.ts
+++ b/tests/client-state.test.ts
@@ -12,6 +12,7 @@ import {
authenticateSiteAccess,
createRoom,
getSiteAccess,
+ getOptionalRuntimeCapabilities,
replaceOwnedRoom,
} from "../src/client/lib/api.ts";
import {
@@ -63,6 +64,34 @@ afterEach(() => {
setCopy({ lang: "zh", vis: false });
});
+describe("optional Viewer capability discovery", () => {
+ it("returns supported progress and tolerates an unavailable or malformed descriptor", async () => {
+ for (const [body, status, expected] of [
+ [{ connectionAttemptProgress4: true }, 200, true],
+ [{}, 200, undefined],
+ [{}, 503, undefined],
+ [{ connectionAttemptProgress4: 4 }, 200, undefined],
+ ] as const) {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })));
+ expect((await getOptionalRuntimeCapabilities())?.connectionAttemptProgress4).toBe(expected);
+ }
+ });
+
+ it("abandons stalled discovery without preventing viewing", async () => {
+ vi.useFakeTimers();
+ let observed: AbortSignal | undefined;
+ vi.stubGlobal("fetch", vi.fn((_url: string, options: RequestInit) => new Promise((_resolve, reject) => {
+ observed = options.signal!;
+ observed.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
+ })));
+ const discovery = getOptionalRuntimeCapabilities();
+ await vi.advanceTimersByTimeAsync(2_000);
+ await expect(discovery).resolves.toBeNull();
+ expect(observed?.aborted).toBe(true);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
+
function routeAuthenticated(
shareGeneration: string | null = null,
qualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS,
diff --git a/tests/media-status.test.ts b/tests/media-status.test.ts
index 3ef808b..6ad1e13 100644
--- a/tests/media-status.test.ts
+++ b/tests/media-status.test.ts
@@ -51,7 +51,7 @@ describe("media status projection", () => {
it.each(["reconnecting", "failed"] as const)("shows media recovery only on the television: %s", (connection) => {
const result = deriveViewerStatus(presentation({ type: "connection", revision: 1, connection }), "connected");
expect(result.television).toMatchObject({
- tone: "warn", icon: "refresh", labelKey: "viewer.notice.mediaRecovering",
+ tone: "warn", icon: "refresh", labelKey: "viewer.msg.recovering",
});
expect(result.notice).toBeNull();
expect(result.overlay).toBeNull();
@@ -68,6 +68,59 @@ describe("media status projection", () => {
expect(result.overlay).toBeNull();
});
+ it.each(["reconnecting", "failed"] as const)("does not let coexisting notices hide media %s", connection => {
+ for (const host of ["online", "offline"] as const) {
+ for (const signal of ["connected", "reconnecting"] as const) {
+ const state = [
+ ...playingActions,
+ { type: "host", host } as const,
+ { type: "signal", signal } as const,
+ { type: "connection", revision: 1, connection } as const,
+ ].reduce(reduceViewerPresentation, INITIAL_VIEWER_PRESENTATION_STATE);
+ const status = deriveViewerStatus(deriveViewerPresentation(state), signal);
+ expect(status.television).toMatchObject({ tone: "warn", comic: "recovering" });
+ expect(status.overlay).toBeNull();
+ expect(status.notice?.labelKey ?? null).toBe(host === "offline"
+ ? "viewer.notice.hostOffline" : signal === "reconnecting" ? "viewer.notice.signalRecovering" : null);
+ const recovered = reduceViewerPresentation(state, { type: "frame-presented", generation: 1, revision: 1, proofEpoch: 0 });
+ const healthy = deriveViewerStatus(deriveViewerPresentation(recovered), signal);
+ expect(healthy.television).toMatchObject({ tone: "live", labelKey: "viewer.msg.playing" });
+ expect(healthy.notice).toEqual(status.notice);
+ }
+ }
+ });
+
+ it.each([
+ ["unknown", "viewer.msg.waitingHost", "waiting-for-host", "viewerWaiting"],
+ ["stopped", "viewer.msg.waitingHost", "waiting-for-host", "viewerWaiting"],
+ ["offline", "viewer.msg.hostOffline", "host-offline", "viewerUnavailable"],
+ ["paused", "viewer.msg.hostPaused", "host-paused", "paused"],
+ ] as const)("explains absent Host %s instead of exhausted routes in either event order", (host, labelKey, comic, titleFrameKey) => {
+ const absence: ViewerPresentationAction = { type: "host", host };
+ const failure: ViewerPresentationAction = { type: "route-status", revision: 1, state: "failed" };
+ for (const hadPicture of [false, true]) {
+ for (const kind of ["p2p", "sfu"] as const) {
+ for (const order of [[absence, failure], [failure, absence]]) {
+ const state = [
+ ...(hadPicture ? playingActions : playingActions.slice(0, 4)),
+ { type: "route", revision: 1, phase: "active", kind } as const,
+ ...order,
+ ].reduce(reduceViewerPresentation, INITIAL_VIEWER_PRESENTATION_STATE);
+ const status = deriveViewerStatus(deriveViewerPresentation(state), "connected", kind);
+ expect(status).toMatchObject({
+ television: { labelKey, comic },
+ overlay: { mode: hadPicture ? "status" : "blocking", status: { labelKey, comic } },
+ titleFrameKey,
+ });
+ // Host return cannot by itself turn an exhausted route into a healthy one.
+ expect(state.routeStatus).toEqual({ revision: 1, state: "failed" });
+ const returned = reduceViewerPresentation(state, { type: "host", host: "online" });
+ expect(deriveViewerPresentation(returned).stage).toBe("route-failed");
+ }
+ }
+ }
+ });
+
it("keeps Host source activity separate from signaling and ignores a previous share's pause", () => {
const sharing = deriveHostStatus({ phase: "live", paused: false, signal: "reconnecting", roomReady: true });
expect(sharing.television.tone).toBe("live");
diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts
index d72b862..2a081bb 100644
--- a/tests/protocol.test.ts
+++ b/tests/protocol.test.ts
@@ -35,6 +35,17 @@ const qualitySettings = {
} as const;
describe("runtime capabilities", () => {
+ it("keeps progress opt-in optional and strict for either signaling role", () => {
+ for (const role of ["host", "viewer"] as const) {
+ const identity = { type: "authenticate", protocol: SIGNALING_PROTOCOL, roomId,
+ clientId: "client_12345678", role, ...(role === "host" ? { token } : {}) };
+ expect(clientMessageSchema.safeParse(identity).success).toBe(true);
+ for (const connectionAttemptProgress4 of [true, false, null, 4, "true"]) {
+ expect(clientMessageSchema.safeParse({ ...identity, connectionAttemptProgress4 }).success)
+ .toBe(connectionAttemptProgress4 === true);
+ }
+ }
+ });
it("defaults missing capabilities off and ignores unknown descriptors", () => {
expect(runtimeCapabilitiesSchema.parse({})).toEqual({ sfu: false, natPrediction: false });
expect(runtimeCapabilitiesSchema.parse({ sfu: true, extra: { enabled: true } }))
@@ -43,11 +54,14 @@ describe("runtime capabilities", () => {
.toEqual({ sfu: false, natPrediction: true });
expect(runtimeCapabilitiesSchema.parse({ sfu: true, natPrediction: true }))
.toEqual({ sfu: true, natPrediction: true });
+ expect(runtimeCapabilitiesSchema.parse({ connectionAttemptProgress4: true }))
+ .toEqual({ sfu: false, natPrediction: false, connectionAttemptProgress4: true });
});
it("rejects malformed known capabilities and non-object responses", () => {
for (const value of [null, [], true, { sfu: null }, { sfu: "true" },
- { natPrediction: null }, { natPrediction: 1 }]) {
+ { natPrediction: null }, { natPrediction: 1 },
+ { connectionAttemptProgress4: null }, { connectionAttemptProgress4: 4 }]) {
expect(runtimeCapabilitiesSchema.safeParse(value).success).toBe(false);
}
});
@@ -1492,14 +1506,16 @@ describe("server signaling protocol", () => {
childPeerId: "child_12345678", connectionId: "connection_12345678",
transport: "direct", qualityProbe: false,
};
- for (const current of [1, 2, 3]) {
- expect(preparedRouteCandidateSchema.safeParse({
- ...candidate, connectionAttempt: { current, total: 3 },
- }).success).toBe(true);
+ for (const total of [3, 4]) {
+ for (let current = 1; current <= total; current++) {
+ expect(preparedRouteCandidateSchema.safeParse({
+ ...candidate, connectionAttempt: { current, total },
+ }).success).toBe(true);
+ }
}
for (const connectionAttempt of [
{ current: 0, total: 3 }, { current: 4, total: 3 },
- { current: 1, total: 4 }, { current: 1.5, total: 3 },
+ { current: 5, total: 4 }, { current: 1, total: 5 }, { current: 1.5, total: 3 },
]) {
expect(preparedRouteCandidateSchema.safeParse({
...candidate, connectionAttempt,
diff --git a/tests/viewer-presentation.test.ts b/tests/viewer-presentation.test.ts
index 7743d2b..f3c1178 100644
--- a/tests/viewer-presentation.test.ts
+++ b/tests/viewer-presentation.test.ts
@@ -336,7 +336,7 @@ describe("Viewer presentation reducer", () => {
{ type: "signal", signal: "reconnecting" },
);
expect(deriveViewerPresentation(state)).toMatchObject({
- stage: "recovering",
+ stage: "playing",
overlay: "none",
noticeKey: "viewer.notice.signalRecovering",
});
@@ -599,8 +599,8 @@ describe("Viewer presentation reducer", () => {
);
expect(deriveViewerPresentation(reconnecting)).toMatchObject({
- stage: "recovering",
- messageKey: "viewer.msg.recovering",
+ stage: "playing",
+ messageKey: "viewer.msg.playing",
overlay: "none",
noticeKey: "viewer.notice.signalRecovering",
});
diff --git a/tests/waiting-status.test.ts b/tests/waiting-status.test.ts
index 99ff39f..d659f8b 100644
--- a/tests/waiting-status.test.ts
+++ b/tests/waiting-status.test.ts
@@ -48,8 +48,9 @@ it.each(["host-paused", "preview-paused", "route-failed", "source-failed", "room
it.each(STATUS_SCENARIOS)("composes feedback from the actual $id state", scenario => {
const presentation = deriveViewerPresentation(scenario.state);
const { overlay } = deriveViewerStatus(presentation, scenario.state.signal);
- const waiting = ["joining", "preparing-p2p", "preparing-sfu", "waiting-sfu", "allocating",
- "receiving", "recovering", "waiting-host", "stopped"].includes(scenario.id);
+ const waiting = presentation.overlay !== "none" &&
+ ["joining", "preparing-p2p", "preparing-sfu", "waiting-sfu", "allocating",
+ "receiving", "recovering", "waiting-host"].includes(presentation.stage);
expect(overlay?.waiting ?? false).toBe(waiting);
if (!overlay) return;
const html = renderToStaticMarkup(createElement(StageOverlay, {