fix(viewer): align lifecycle and native controls (#260)

This commit is contained in:
AAA authored and GitHub committed 2026-08-25 17:07:53 +08:00
1 parent cbaa689a0f
commit 43ff37bed9
11 files changed
+219 -451

No files matched your search

+4
View File
@@ -9,6 +9,10 @@ export class DecodedFrameStallDetector {
setPaused(paused: boolean, nowMs = Date.now()): void {
if (this.paused === paused) return;
this.paused = paused;
this.rebaseline(nowMs);
}
rebaseline(nowMs = Date.now()): void {
this.lastDecodedAt = nowMs;
this.reported = false;
}
+3 -7
View File
@@ -134,7 +134,6 @@ export interface ViewerPresentation {
message: string;
notice: string | null;
overlay: "none" | "status" | "blocking";
showPlay: boolean;
retryAvailable: boolean;
hasCurrentFrame: boolean;
hasRetainedFrame: boolean;
@@ -310,6 +309,7 @@ export function reduceViewerPresentation(
}
return {
...state,
connection: "connected",
media: { ...state.media, framePresented: true },
retainedFrame: false,
failure:
@@ -486,10 +486,7 @@ export function deriveViewerPresentation(
state.autoplayBlockedGeneration === state.media.generation &&
state.connection === "connected"
) {
return {
...presentation("needs-play", "需要点击播放", "blocking", state),
showPlay: true,
};
return presentation("needs-play", "点击播放", "status", state);
}
if (currentFrame) {
@@ -505,7 +502,7 @@ export function deriveViewerPresentation(
state,
),
notice: state.host === "offline"
? "分享者连接已中断,画面仍然可用"
? "分享者连接已中断,画面可能冻结"
: signalRecovering
? "服务器连接正在恢复,画面仍在播放"
: mediaRecovering
@@ -666,7 +663,6 @@ function presentation(
message,
notice: null,
overlay,
showPlay: false,
retryAvailable:
state.access === "ready" && state.host !== "paused" && state.retryAvailable,
hasCurrentFrame: hasCurrentFrame(state),
-63
View File
@@ -1,63 +0,0 @@
export interface ViewerVolumeState {
volumePercent: number;
muted: boolean;
lastNonZeroVolumePercent: number;
}
interface ViewerVolumeTarget {
volume: number;
muted: boolean;
}
export const DEFAULT_VIEWER_VOLUME_STATE: ViewerVolumeState = {
volumePercent: 100,
muted: false,
lastNonZeroVolumePercent: 100,
};
function normalizeVolumePercent(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(value)));
}
export function setViewerVolume(
current: ViewerVolumeState,
requestedPercent: number,
): ViewerVolumeState {
const volumePercent = normalizeVolumePercent(requestedPercent);
if (volumePercent === 0) {
return { ...current, volumePercent: 0, muted: true };
}
return {
volumePercent,
muted: false,
lastNonZeroVolumePercent: volumePercent,
};
}
export function toggleViewerMuted(
current: ViewerVolumeState,
): ViewerVolumeState {
if (!current.muted) {
return { ...current, muted: true };
}
const volumePercent =
current.volumePercent > 0
? current.volumePercent
: Math.max(1, current.lastNonZeroVolumePercent);
return {
volumePercent,
muted: false,
lastNonZeroVolumePercent: volumePercent,
};
}
export function applyViewerVolume(
target: ViewerVolumeTarget,
state: ViewerVolumeState,
): void {
target.volume = state.volumePercent / 100;
target.muted = state.muted;
}
+1 -31
View File
@@ -7,7 +7,6 @@ import {
KeyRound,
Link2Off,
LockKeyhole,
Maximize2,
MonitorUp,
Network,
Pause,
@@ -884,24 +883,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
);
}
async function enterPreviewFullscreen(): Promise<void> {
const video = videoRef.current as
| (HTMLVideoElement & { webkitEnterFullscreen?: () => void })
| null;
if (!video) {
return;
}
try {
if (video.requestFullscreen) {
await video.requestFullscreen();
} else {
video.webkitEnterFullscreen?.();
}
} catch {
setNotice("当前浏览器无法放大本地预览");
}
}
function removePeer(peerId: string): void {
clearViewerQualityEvidence(peerId);
const peer = peersRef.current.get(peerId);
@@ -2102,18 +2083,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
aria-label="分享或加入房间"
>
{stream ? (
<>
<video ref={videoRef} autoPlay muted playsInline />
<button
className="icon-button local-preview-action"
type="button"
title="放大本地预览"
aria-label="放大本地预览"
onClick={() => void enterPreviewFullscreen()}
>
<Maximize2 size={18} aria-hidden="true" />
</button>
</>
<video ref={videoRef} autoPlay muted playsInline />
) : phase === "idle" ||
phase === "ended" ||
phase === "error" ? (
+127 -202
View File
@@ -1,15 +1,11 @@
import {
KeyRound,
LoaderCircle,
Maximize2,
Network,
Pencil,
Play,
RefreshCw,
Save,
VideoOff,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
@@ -68,12 +64,6 @@ import {
} from "../media/video-frame-proof";
import { exactPeerSignalOwner } from "../media/route-transition";
import { ViewerSfuRoute } from "../media/viewer-sfu-route";
import {
applyViewerVolume,
DEFAULT_VIEWER_VOLUME_STATE,
setViewerVolume,
toggleViewerMuted,
} from "../media/viewer-volume";
import type {
ConnectionMetrics,
PeerSnapshot,
@@ -130,7 +120,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
const [remoteMedia, setRemoteMedia] = useState<RemoteMediaBinding | null>(
null,
);
const remoteStream = remoteMedia?.stream ?? null;
const [peerSnapshot, setPeerSnapshot] = useState<PeerSnapshot | null>(null);
const [sfuUpstream, setSfuUpstream] = useState<SfuUpstreamState | null>(null);
const [assignedRoute, setAssignedRoute] = useState<{
@@ -141,9 +130,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
const [relaySnapshot, setRelaySnapshot] = useState<PeerSnapshot | null>(null);
const [relayChildEvidence, setRelayChildEvidence] =
useState<ViewerQualityEvidencePresentation | null>(null);
const [playbackVolume, setPlaybackVolume] = useState(
DEFAULT_VIEWER_VOLUME_STATE,
);
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
const [showTopology, setShowTopology] = useState(false);
const [displayName, setDisplayName] = useState(() => readDisplayName());
@@ -162,9 +148,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
sequence: number;
} | null>(null);
const [viewerPasswordExpanded, setViewerPasswordExpanded] = useState(false);
const [localNotice, setLocalNotice] = useState<
"fullscreen-unavailable" | null
>(null);
const [frameProofEpoch, setFrameProofEpoch] = useState(0);
const qualityLimitation = useMemo(
() =>
@@ -206,7 +190,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
const displayNameRef = useRef(displayName);
const remoteMediaRef = useRef<RemoteMediaBinding | null>(null);
const mediaGenerationRef = useRef(0);
const { muted, volumePercent } = playbackVolume;
const hostPlaybackPauseRef = useRef({ active: false, resume: false });
const routePresentation = viewerRouteEvidence(
assignedRoute?.upstream ?? null,
peerSnapshot,
@@ -252,6 +236,34 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
dispatchPresentation({ type: "media-cleared" });
}
function attemptPlayback(
video: HTMLVideoElement,
binding: RemoteMediaBinding,
): void {
void video.play().then(
() =>
dispatchPresentation({
type: "autoplay-cleared",
generation: binding.generation,
}),
(error: unknown) => {
if (isAutoplayPolicyRejection(error)) {
dispatchPresentation({
type: "autoplay-blocked",
generation: binding.generation,
revision: binding.revision,
});
} else {
dispatchPresentation({
type: "playback-failed",
generation: binding.generation,
revision: binding.revision,
});
}
},
);
}
function acceptAssignedRoute(
revision: number,
upstream: ParticipantRouteAssignment["upstream"],
@@ -306,6 +318,22 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
null;
let relayChildEvidenceTimer: number | null = null;
const rebaselineAfterResume = (): void => {
decodedFrameStall.rebaseline();
};
const rebaselineAfterVisibilityChange = (): void => {
if (document.visibilityState !== "visible") {
return;
}
decodedFrameStall.rebaseline();
setFrameProofEpoch((current) => current + 1);
};
document.addEventListener("resume", rebaselineAfterResume);
document.addEventListener(
"visibilitychange",
rebaselineAfterVisibilityChange,
);
function setSfuStandbyUrl(url: string | null | undefined): void {
if (!url) {
sfuStandbyPrewarmer?.setUrl(null);
@@ -1428,6 +1456,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
signal.start();
return () => {
active = false;
document.removeEventListener("resume", rebaselineAfterResume);
document.removeEventListener(
"visibilitychange",
rebaselineAfterVisibilityChange,
);
currentPeerId = null;
qualityEvidenceReporter.reset();
clearRelayChildEvidence();
@@ -1457,141 +1490,69 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
return;
}
video.srcObject = remoteMedia.stream;
const stopObserving = observeCompositedVideoFrame(
video,
remoteMedia.stream,
() =>
dispatchPresentation({
type: "frame-presented",
generation: remoteMedia.generation,
revision: remoteMedia.revision,
}),
);
void video.play().then(
() =>
dispatchPresentation({
type: "autoplay-cleared",
generation: remoteMedia.generation,
}),
(error: unknown) => {
if (isAutoplayPolicyRejection(error)) {
dispatchPresentation({
type: "autoplay-blocked",
generation: remoteMedia.generation,
revision: remoteMedia.revision,
});
} else {
dispatchPresentation({
type: "playback-failed",
generation: remoteMedia.generation,
revision: remoteMedia.revision,
});
}
},
);
return stopObserving;
if (presentationState.host === "paused") {
video.pause();
return;
}
attemptPlayback(video, remoteMedia);
}, [remoteMedia]);
useEffect(() => {
const paused = presentationState.host === "paused";
const pauseState = hostPlaybackPauseRef.current;
const video = videoRef.current;
if (!video) {
if (paused) {
if (!pauseState.active) {
pauseState.active = true;
pauseState.resume = video && remoteMedia ? !video.paused : true;
}
video?.pause();
return;
}
applyViewerVolume(video, playbackVolume);
}, [muted, remoteMedia, volumePercent]);
if (!pauseState.active) {
return;
}
pauseState.active = false;
const shouldResume = pauseState.resume;
pauseState.resume = false;
if (shouldResume && video && remoteMedia) {
attemptPlayback(video, remoteMedia);
}
}, [presentationState.host, remoteMedia]);
useEffect(() => {
const video = videoRef.current;
if (!video || !remoteMedia) {
return;
}
return observeCompositedVideoFrame(video, remoteMedia.stream, () =>
dispatchPresentation({
type: "frame-presented",
generation: remoteMedia.generation,
revision: remoteMedia.revision,
}),
);
}, [frameProofEpoch, presentationState.connection, remoteMedia]);
useEffect(() => {
dispatchPresentation({
type: "retry-available",
available: Boolean(
peerSnapshot ||
(assignedRoute?.phase === "active" &&
assignedRoute.upstream.kind === "sfu"),
signalStatus === "connected" &&
(peerSnapshot ||
(assignedRoute?.phase === "active" &&
assignedRoute.upstream.kind === "sfu")),
),
});
}, [assignedRoute, peerSnapshot]);
async function playVideo(): Promise<void> {
const binding = remoteMediaRef.current;
if (!videoRef.current || !binding) {
return;
}
try {
await videoRef.current.play();
dispatchPresentation({
type: "autoplay-cleared",
generation: binding.generation,
});
} catch (error) {
if (isAutoplayPolicyRejection(error)) {
dispatchPresentation({
type: "autoplay-blocked",
generation: binding.generation,
revision: binding.revision,
});
} else {
dispatchPresentation({
type: "playback-failed",
generation: binding.generation,
revision: binding.revision,
});
}
}
}
function toggleMuted(): void {
const next = toggleViewerMuted(playbackVolume);
setPlaybackVolume(next);
if (videoRef.current) {
applyViewerVolume(videoRef.current, next);
}
if (!next.muted) {
void playVideo();
}
}
function changeVolume(nextPercent: number): void {
const next = setViewerVolume(playbackVolume, nextPercent);
setPlaybackVolume(next);
if (videoRef.current) {
applyViewerVolume(videoRef.current, next);
}
if (!next.muted) {
void playVideo();
}
}
async function enterFullscreen(): Promise<void> {
const video = videoRef.current as
| (HTMLVideoElement & { webkitEnterFullscreen?: () => void })
| null;
if (!video) {
return;
}
try {
if (video.requestFullscreen) {
await video.requestFullscreen();
} else {
video.webkitEnterFullscreen?.();
}
} catch {
setLocalNotice("fullscreen-unavailable");
}
}
}, [assignedRoute, peerSnapshot, signalStatus]);
function retryConnection(): void {
const requested =
assignedRoute?.phase === "active" &&
assignedRoute.upstream.kind === "sfu"
? signalRef.current?.reconnect() === true
: peerRef.current?.requestRecovery() === true;
if (!requested) {
dispatchPresentation({
type: "connection",
revision: assignedRoute?.revision ?? 0,
connection: hostOnline ? "connecting" : "idle",
});
} else {
: peerRef.current?.requestRecovery(true) === true;
if (requested) {
dispatchPresentation({
type: "connection",
revision: assignedRoute?.revision ?? 0,
@@ -1841,80 +1802,63 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
<video
ref={videoRef}
autoPlay
controls={
presentation.overlay === "none" ||
presentation.stage === "needs-play"
}
playsInline
muted={muted}
onPlay={() => {
const binding = remoteMediaRef.current;
if (binding) {
dispatchPresentation({
type: "autoplay-cleared",
generation: binding.generation,
});
}
}}
/>
{presentation.overlay === "blocking" && !presentation.showPlay && (
{presentation.overlay === "blocking" && (
<div className="stage-placeholder" role="status">
<VideoOff size={36} strokeWidth={1.5} aria-hidden="true" />
<span>{presentation.message}</span>
</div>
)}
{presentation.showPlay && (
<button
type="button"
className="play-overlay"
onClick={() => void playVideo()}
>
<Play size={22} fill="currentColor" aria-hidden="true" />
播放
</button>
)}
{presentation.overlay === "status" && !presentation.showPlay && (
{presentation.overlay === "status" && (
<div className="stage-overlay" role="status">
{presentation.message}
</div>
)}
</section>
<div className="viewer-toolbar">
<div className="viewer-status-row">
<div className="toolbar-status" role="status" aria-live="polite">
{presentation.message}
</div>
<div className="toolbar-actions">
<div className="viewer-volume-control">
<div className="viewer-status-actions">
{labeledHostPresence ? (
<button
className="icon-button viewer-status-action"
type="button"
className="icon-button"
title={muted ? "打开声音" : "静音"}
aria-label={muted ? "打开声音" : "静音"}
onClick={toggleMuted}
title={showTopology ? "隐藏连接拓扑" : "显示连接拓扑"}
aria-label={showTopology ? "隐藏连接拓扑" : "显示连接拓扑"}
aria-controls="room-topology"
aria-expanded={showTopology}
onClick={() => setShowTopology((current) => !current)}
>
{muted ? <VolumeX size={19} /> : <Volume2 size={19} />}
<Network size={18} aria-hidden="true" />
</button>
<input
id="viewer-volume"
type="range"
min="0"
max="100"
step="1"
value={volumePercent}
title="播放音量"
aria-label="播放音量"
aria-valuetext={`${volumePercent}%${muted ? ",已静音" : ""}`}
onChange={(event) => changeVolume(Number(event.target.value))}
/>
<output htmlFor="viewer-volume">{volumePercent}%</output>
</div>
) : (
<span className="viewer-status-action-placeholder" aria-hidden="true" />
)}
<button
type="button"
className="icon-button"
title="恢复连接"
aria-label="恢复连接"
className="icon-button viewer-status-action"
title="重新连接媒体"
aria-label="重新连接媒体"
disabled={!presentation.retryAvailable}
onClick={retryConnection}
>
<RefreshCw size={19} />
</button>
<button
type="button"
className="icon-button"
title="全屏"
aria-label="全屏"
disabled={!remoteStream}
onClick={() => void enterFullscreen()}
>
<Maximize2 size={19} />
<RefreshCw size={18} />
</button>
</div>
</div>
@@ -1924,12 +1868,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
{presentation.notice}
</div>
)}
{localNotice === "fullscreen-unavailable" && (
<div className="notice notice-error" role="status">
当前浏览器无法进入全屏
</div>
)}
{participantPresence && (
<section
className="viewer-roster"
@@ -1940,19 +1878,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
<h2 id="viewer-roster-heading">观看者</h2>
<span>在线 {viewers.length}</span>
</div>
{labeledHostPresence && (
<button
className="icon-button"
type="button"
title={showTopology ? "隐藏连接拓扑" : "显示连接拓扑"}
aria-label={showTopology ? "隐藏连接拓扑" : "显示连接拓扑"}
aria-controls="room-topology"
aria-expanded={showTopology}
onClick={() => setShowTopology((current) => !current)}
>
<Network size={17} aria-hidden="true" />
</button>
)}
</div>
{showTopology && labeledHostPresence && (
<TopologyView
+23 -68
View File
@@ -314,14 +314,6 @@ h3 {
background: #050706;
}
.local-preview-action {
position: absolute;
z-index: 2;
top: 10px;
right: 10px;
background: rgba(5, 7, 6, 0.78);
}
.stage-placeholder,
.stage-overlay {
position: absolute;
@@ -337,6 +329,7 @@ h3 {
.stage-overlay {
color: var(--text);
background: rgba(5, 7, 6, 0.72);
pointer-events: none;
}
.stage-entry {
@@ -710,8 +703,7 @@ h3 {
}
.button,
.icon-button,
.play-overlay {
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1282,27 +1274,14 @@ h3 {
margin: 0 auto;
}
.play-overlay {
position: absolute;
inset: 50% auto auto 50%;
.viewer-status-row {
display: grid;
width: 100%;
min-height: 44px;
grid-template-columns: minmax(0, 1fr) 84px;
gap: 8px;
padding: 0 18px;
color: #06140c;
background: var(--green);
border-color: var(--green-strong);
font-weight: 700;
transform: translate(-50%, -50%);
}
.viewer-toolbar {
display: flex;
min-height: 58px;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 9px 0;
border-bottom: 1px solid var(--border);
padding: 4px 0;
}
.viewer-roster {
@@ -1371,33 +1350,27 @@ h3 {
white-space: nowrap;
}
.toolbar-actions {
display: flex;
min-width: 0;
flex: 0 0 auto;
gap: 8px;
align-items: center;
}
.viewer-volume-control {
.viewer-status-actions {
display: grid;
min-width: 0;
grid-template-columns: 40px minmax(76px, 120px) 42px;
gap: 8px;
align-items: center;
grid-template-columns: 40px 40px;
gap: 4px;
}
.viewer-volume-control input {
width: 100%;
min-width: 0;
accent-color: var(--green);
.viewer-status-action,
.viewer-status-action-placeholder {
width: 40px;
height: 40px;
}
.viewer-volume-control output {
color: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
text-align: right;
.viewer-status-action {
border-color: transparent;
color: var(--faint);
background: transparent;
}
.viewer-status-action:hover:not(:disabled) {
color: var(--text);
background: var(--surface);
}
.viewer-stats {
@@ -1504,24 +1477,6 @@ h3 {
width: 100%;
}
.viewer-toolbar {
flex-wrap: wrap;
gap: 6px;
}
.toolbar-status {
flex: 1 1 100%;
}
.toolbar-actions {
width: 100%;
}
.viewer-volume-control {
flex: 1 1 auto;
grid-template-columns: 40px minmax(58px, 1fr) 38px;
}
.segmented-control {
height: auto;
min-height: 42px;
+3 -3
View File
@@ -112,20 +112,20 @@ export class ViewerPeer {
}
}
requestRecovery(): boolean {
requestRecovery(rebuild = false): boolean {
if (!this.connectionId || !this.parentPeerId) {
return false;
}
const sent = this.events.sendRestartRequest(
this.parentPeerId,
this.connectionId,
false,
rebuild,
);
if (sent) {
this.clearInitialConnectionTimer();
this.automaticRecoveryRequests = Math.max(
this.automaticRecoveryRequests,
1,
rebuild ? MAX_AUTOMATIC_RECOVERY_REQUESTS : 1,
);
this.scheduleRecoveryDeadline();
}
+21
View File
@@ -41,4 +41,25 @@ describe("DecodedFrameStallDetector", () => {
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 4),
).toBe(true);
});
it("rebases elapsed time after the page resumes", () => {
const detector = new DecodedFrameStallDetector();
detector.observe("peer:1:a", 0, 0);
detector.rebaseline(ROUTE_DECODED_FRAME_STALL_MS * 4);
expect(
detector.observe(
"peer:1:a",
null,
ROUTE_DECODED_FRAME_STALL_MS * 5 - 1,
),
).toBe(false);
expect(
detector.observe(
"peer:1:a",
0,
ROUTE_DECODED_FRAME_STALL_MS * 5,
),
).toBe(true);
});
});
+7
View File
@@ -619,6 +619,13 @@ describe("ViewerPeer connection generations", () => {
rebuild: false,
},
]);
expect(peer.requestRecovery(true)).toBe(true);
expect(restartRequests.at(-1)).toEqual({
peerId: "parent-new",
connectionId: "connection-new",
rebuild: true,
});
});
it("does not send an old answer or candidate after replacing the connection", async () => {
+30 -12
View File
@@ -29,7 +29,6 @@ describe("Viewer presentation reducer", () => {
stage: "preparing-p2p",
message: "正在建立 P2P",
overlay: "blocking",
showPlay: false,
});
const receiving = reduceViewerPresentation(preparing, {
@@ -51,7 +50,7 @@ describe("Viewer presentation reducer", () => {
});
});
it("shows Play only for the exact media generation rejected by autoplay", () => {
it("reports native playback blocking only for the exact media generation", () => {
const blocked = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
@@ -62,7 +61,8 @@ describe("Viewer presentation reducer", () => {
);
expect(deriveViewerPresentation(blocked)).toMatchObject({
stage: "needs-play",
showPlay: true,
message: "点击播放",
overlay: "status",
failureCode: "AUTOPLAY_BLOCKED",
});
@@ -70,7 +70,7 @@ describe("Viewer presentation reducer", () => {
type: "autoplay-cleared",
generation: 3,
});
expect(deriveViewerPresentation(staleClear).showPlay).toBe(true);
expect(deriveViewerPresentation(staleClear).stage).toBe("needs-play");
const cleared = reduceViewerPresentation(blocked, {
type: "autoplay-cleared",
@@ -78,11 +78,10 @@ describe("Viewer presentation reducer", () => {
});
expect(deriveViewerPresentation(cleared)).toMatchObject({
stage: "receiving",
showPlay: false,
});
});
it("waits for the current media connection before showing Play", () => {
it("waits for the current media connection before reporting playback blocking", () => {
const blockedWhileConnecting = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
@@ -92,7 +91,6 @@ describe("Viewer presentation reducer", () => {
);
expect(deriveViewerPresentation(blockedWhileConnecting)).toMatchObject({
stage: "receiving",
showPlay: false,
});
const connected = reduceViewerPresentation(blockedWhileConnecting, {
@@ -102,7 +100,7 @@ describe("Viewer presentation reducer", () => {
});
expect(deriveViewerPresentation(connected)).toMatchObject({
stage: "needs-play",
showPlay: true,
overlay: "status",
});
});
@@ -170,7 +168,6 @@ describe("Viewer presentation reducer", () => {
expect(deriveViewerPresentation(preparing)).toMatchObject({
stage: "preparing-sfu",
overlay: "status",
showPlay: false,
});
});
@@ -193,7 +190,6 @@ describe("Viewer presentation reducer", () => {
expect(preparing.failure).toBeNull();
expect(deriveViewerPresentation(preparing)).toMatchObject({
stage: "preparing-sfu",
showPlay: false,
});
});
@@ -259,6 +255,29 @@ describe("Viewer presentation reducer", () => {
});
});
it("treats a new current-generation frame as connected media", () => {
const recovering = apply(
{ type: "access", access: "ready" },
{ type: "signal", signal: "connected" },
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "connection", revision: 3, connection: "reconnecting" },
);
const resumed = reduceViewerPresentation(recovering, {
type: "frame-presented",
generation: 2,
revision: 3,
});
expect(resumed.connection).toBe("connected");
expect(deriveViewerPresentation(resumed)).toMatchObject({
stage: "playing",
message: "正在播放",
notice: null,
});
});
it("keeps a healthy Host-offline frame but invalidates it on upstream failure", () => {
const offlineWithMedia = apply(
{ type: "access", access: "ready" },
@@ -273,7 +292,7 @@ describe("Viewer presentation reducer", () => {
stage: "playing",
overlay: "none",
hasCurrentFrame: true,
notice: "分享者连接已中断,画面仍然可用",
notice: "分享者连接已中断,画面可能冻结",
});
expect(
reduceViewerPresentation(offlineWithMedia, {
@@ -330,7 +349,6 @@ describe("Viewer presentation reducer", () => {
);
expect(deriveViewerPresentation(paused)).toMatchObject({
stage: "host-paused",
showPlay: false,
});
const waiting = apply(
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import {
applyViewerVolume,
DEFAULT_VIEWER_VOLUME_STATE,
setViewerVolume,
toggleViewerMuted,
} from "../src/client/media/viewer-volume.ts";
describe("viewer page-local volume", () => {
it("starts audible at full volume for each page load", () => {
expect(DEFAULT_VIEWER_VOLUME_STATE).toEqual({
volumePercent: 100,
muted: false,
lastNonZeroVolumePercent: 100,
});
});
it("keeps the selected volume while mute is toggled", () => {
const selected = setViewerVolume(DEFAULT_VIEWER_VOLUME_STATE, 64);
const muted = toggleViewerMuted(selected);
expect(muted).toEqual({
volumePercent: 64,
muted: true,
lastNonZeroVolumePercent: 64,
});
expect(toggleViewerMuted(muted)).toEqual(selected);
});
it("mutes at zero and restores the last non-zero volume", () => {
const selected = setViewerVolume(DEFAULT_VIEWER_VOLUME_STATE, 37);
const zero = setViewerVolume(selected, 0);
expect(zero).toEqual({
volumePercent: 0,
muted: true,
lastNonZeroVolumePercent: 37,
});
expect(toggleViewerMuted(zero)).toEqual(selected);
});
it("automatically unmutes and bounds a non-zero slider value", () => {
const muted = toggleViewerMuted(DEFAULT_VIEWER_VOLUME_STATE);
expect(setViewerVolume(muted, 42)).toEqual({
volumePercent: 42,
muted: false,
lastNonZeroVolumePercent: 42,
});
expect(setViewerVolume(muted, 120).volumePercent).toBe(100);
expect(setViewerVolume(muted, Number.NaN).volumePercent).toBe(0);
});
it("synchronously applies state to the current media element", () => {
const target = { volume: 1, muted: false };
const zero = setViewerVolume(DEFAULT_VIEWER_VOLUME_STATE, 0);
applyViewerVolume(target, zero);
expect(target).toEqual({ volume: 0, muted: true });
applyViewerVolume(target, toggleViewerMuted(zero));
expect(target).toEqual({ volume: 1, muted: false });
});
});