fix(viewer): align lifecycle and native controls (#260)
This commit is contained in:
1 parent
cbaa689a0f
commit
43ff37bed9
11 files changed
+219
-451
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user