3007 lines
99 KiB
TypeScript
3007 lines
99 KiB
TypeScript
import {
|
|
Check,
|
|
Copy,
|
|
Eye,
|
|
EyeOff,
|
|
Globe2,
|
|
KeyRound,
|
|
Link2Off,
|
|
LoaderCircle,
|
|
LockKeyhole,
|
|
MonitorUp,
|
|
Network,
|
|
Pause,
|
|
Pencil,
|
|
Play,
|
|
RefreshCw,
|
|
Save,
|
|
Square,
|
|
Users,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
DEFAULT_HOST_DISPLAY_NAME_PREFIX,
|
|
DEFAULT_QUALITY_SETTINGS,
|
|
DEFAULT_ROUTE_POLICY,
|
|
MAX_VIEWER_PASSWORD_LENGTH,
|
|
viewerPasswordSchema,
|
|
type CreateRoomResponse,
|
|
type IceConfig,
|
|
type ParticipantPresenceEntry,
|
|
type PreparedRouteCandidate,
|
|
type ServerMessage,
|
|
type CodeEntryPolicy,
|
|
type RoutePolicy,
|
|
} from "../../shared/protocol";
|
|
import { AppHeader } from "../components/AppHeader";
|
|
import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
|
|
import { RoomCode } from "../components/RoomCode";
|
|
import { StageEntryActions } from "../components/StageEntryActions";
|
|
import { qualityLimitationSummary } from "../components/connection-details";
|
|
import {
|
|
MediaRouteBadge,
|
|
PeerStatusBadge,
|
|
SignalStatusBadge,
|
|
WarningBanner,
|
|
} from "../components/StatusBadge";
|
|
import { StatsGrid } from "../components/StatsGrid";
|
|
import { TopologyView } from "../components/TopologyView";
|
|
import { hasPeerRouteEvidence } from "../components/status-badge-model";
|
|
import {
|
|
ApiError,
|
|
createRoom,
|
|
replaceOwnedRoom,
|
|
updateRoomAccess,
|
|
} from "../lib/api";
|
|
import {
|
|
readCreationProfile,
|
|
saveCreationProfile,
|
|
type HostCreationProfile,
|
|
} from "../lib/creation-profile";
|
|
import { createOpaqueId } from "../lib/opaque-id";
|
|
import {
|
|
defaultHostDisplayName,
|
|
readDisplayName,
|
|
saveDisplayName,
|
|
} from "../lib/display-name";
|
|
import {
|
|
clearHostRoom,
|
|
getStableClientId,
|
|
type HostRoomIdentity,
|
|
type HostRoomState,
|
|
mergeAuthenticatedHostRoom,
|
|
readHostRoom,
|
|
readPreferredRoomId,
|
|
readViewerGrant,
|
|
replaceViewerInvite,
|
|
writeHostRoom,
|
|
writePreferredRoom,
|
|
} from "../lib/session";
|
|
import {
|
|
SignalingClient,
|
|
type SignalingTerminationReason,
|
|
} from "../lib/signaling";
|
|
import { labelParticipantSnapshot } from "../lib/viewer-presence";
|
|
import {
|
|
applyCaptureProfile,
|
|
captureDisplay,
|
|
DEGRADATION_PREFERENCE_HINTS,
|
|
DEGRADATION_PREFERENCE_LABELS,
|
|
matchingQualityProfileId,
|
|
QUALITY_PROFILES,
|
|
QUALITY_PROFILE_LABELS,
|
|
QUALITY_RESOLUTIONS,
|
|
qualitySettingsEqual,
|
|
qualitySettingsLabel,
|
|
resolveScreenAudioQuality,
|
|
SCREEN_AUDIO_BITRATES,
|
|
SCREEN_AUDIO_QUALITY_LABELS,
|
|
setMediaPaused,
|
|
videoQualitySettingsEqual,
|
|
type DegradationPreference,
|
|
type QualityProfileId,
|
|
type QualitySettings,
|
|
type ScreenAudioQuality,
|
|
} from "../media/quality";
|
|
import { HostSfuRoute } from "../media/host-sfu-route";
|
|
import {
|
|
HostProvisionalChild,
|
|
} from "../media/host-provisional-child";
|
|
import { SfuStandbyPrewarmer } from "../media/sfu-standby-prewarmer";
|
|
import {
|
|
invalidateSenderQualityEvidence,
|
|
senderQualityEvidenceFromSnapshot,
|
|
sfuPublisherQualityEvidenceFromMetrics,
|
|
} from "../media/sender-quality-evidence";
|
|
import {
|
|
classifyHostViewerQualityEvidence,
|
|
metricsFromQualityEvidence,
|
|
nextViewerQualityEvidencePresentationExpiryAt,
|
|
presentViewerQualityEvidence,
|
|
qualityEvidenceUpstreamMatches,
|
|
reconcileViewerQualityEvidencePresentation,
|
|
refreshViewerQualityEvidencePresentation,
|
|
type ViewerQualityEvidencePresentation,
|
|
} from "../media/viewer-quality-evidence";
|
|
import type {
|
|
PeerSnapshot,
|
|
SignalConnectionState,
|
|
} from "../types";
|
|
import { HostPeer } from "../webrtc/host-peer";
|
|
import {
|
|
MAX_ENDPOINT_MEDIA_CHILDREN,
|
|
reconcileBoundedMediaChildren,
|
|
} from "../webrtc/media-assignment";
|
|
import {
|
|
automaticVideoCodecPreference,
|
|
manualVideoCodecPreference,
|
|
type BrowserVideoCodec,
|
|
type BrowserVideoCodecMode,
|
|
type BrowserVideoCodecPreference,
|
|
VP8_ONLY_VIDEO_CODEC,
|
|
} from "../webrtc/video-codec";
|
|
import { preferredVideoCodecForTrack } from "../webrtc/video-codec-preflight";
|
|
import {
|
|
hostActionErrorNotice,
|
|
hostServerErrorNotice,
|
|
shouldPauseLocalPreview,
|
|
sourceSwitchNotice,
|
|
type HostAction,
|
|
} from "./host-page-notices";
|
|
|
|
type HostPhase = "idle" | "starting" | "live" | "ended" | "error";
|
|
|
|
type ViewerQualityEvidence = Extract<
|
|
ServerMessage,
|
|
{ type: "viewer-quality-evidence" }
|
|
>;
|
|
type HostRouteAssignment = Extract<
|
|
ServerMessage,
|
|
{ type: "route-update" }
|
|
>["assignment"];
|
|
|
|
interface CaptureDetails {
|
|
resolution: string;
|
|
frameRate: number | null;
|
|
hasAudio: boolean;
|
|
}
|
|
|
|
function captureDetails(stream: MediaStream): CaptureDetails {
|
|
const settings = stream.getVideoTracks()[0]?.getSettings();
|
|
return {
|
|
resolution:
|
|
settings?.width && settings.height
|
|
? `${settings.width}x${settings.height}`
|
|
: "未知",
|
|
frameRate: settings?.frameRate ?? null,
|
|
hasAudio: stream.getAudioTracks().length > 0,
|
|
};
|
|
}
|
|
|
|
function readableError(error: unknown, action: HostAction): string {
|
|
return error instanceof ApiError
|
|
? error.message
|
|
: hostActionErrorNotice(error, action);
|
|
}
|
|
|
|
function closeAbandonedRoom(room: HostRoomIdentity): void {
|
|
try {
|
|
const signal = new SignalingClient(
|
|
{
|
|
roomId: room.roomId,
|
|
role: "host",
|
|
token: room.hostToken,
|
|
clientId: getStableClientId("host", room.roomId),
|
|
shareGeneration: createOpaqueId(),
|
|
routePolicy: DEFAULT_ROUTE_POLICY,
|
|
},
|
|
{
|
|
onMessage: () => undefined,
|
|
onStatus: () => undefined,
|
|
onTerminated: () => undefined,
|
|
onAccessRequired: () => undefined,
|
|
},
|
|
);
|
|
signal.start();
|
|
signal.sendThenStop({ type: "abandon-room" });
|
|
} catch {
|
|
// Best effort only: cancellation must not revive the obsolete generation.
|
|
}
|
|
}
|
|
|
|
function hostRoomFromStored(room: HostRoomIdentity | null): HostRoomState | null {
|
|
if (!room) {
|
|
return null;
|
|
}
|
|
const viewerGrant = readViewerGrant(room.roomId);
|
|
const inviteUrl = new URL(room.canonicalUrl);
|
|
if (viewerGrant) {
|
|
inviteUrl.hash = `v=${viewerGrant}`;
|
|
}
|
|
return {
|
|
...room,
|
|
codeEntryPolicy: null,
|
|
inviteUrl: viewerGrant ? inviteUrl.toString() : null,
|
|
};
|
|
}
|
|
|
|
function hostRoomFromCreated(room: CreateRoomResponse): HostRoomState {
|
|
replaceViewerInvite(room.roomId, room.inviteUrl);
|
|
const canonicalUrl = new URL(room.inviteUrl);
|
|
canonicalUrl.hash = "";
|
|
canonicalUrl.search = "";
|
|
return {
|
|
roomId: room.roomId,
|
|
hostToken: room.hostToken,
|
|
expiresAt: room.expiresAt,
|
|
roomLeaseSeconds: room.roomLeaseSeconds,
|
|
canonicalUrl: canonicalUrl.toString(),
|
|
codeEntryPolicy: room.codeEntryPolicy,
|
|
inviteUrl: room.inviteUrl,
|
|
};
|
|
}
|
|
|
|
function hostTerminationMessage(reason: SignalingTerminationReason): string {
|
|
switch (reason) {
|
|
case "STALE_CLIENT":
|
|
return "页面版本已更新,请刷新后重试";
|
|
case "SESSION_REPLACED":
|
|
return "此页面的会话已被另一个标签页接管";
|
|
case "SIGNAL_TERMINATED":
|
|
return "服务器连接已终止,请刷新后重试";
|
|
}
|
|
}
|
|
|
|
interface HostPageProps {
|
|
onAuthorizationRequired?: () => void;
|
|
}
|
|
|
|
export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
|
const [qualitySettings, setQualitySettings] = useState<QualitySettings>(
|
|
DEFAULT_QUALITY_SETTINGS,
|
|
);
|
|
const [advancedQuality, setAdvancedQuality] = useState<QualitySettings>(
|
|
DEFAULT_QUALITY_SETTINGS,
|
|
);
|
|
const [routePolicy, setRoutePolicy] = useState<RoutePolicy>(
|
|
DEFAULT_ROUTE_POLICY,
|
|
);
|
|
const [videoCodecMode, setVideoCodecMode] =
|
|
useState<BrowserVideoCodecMode>("auto");
|
|
const [resolvedVideoCodec, setResolvedVideoCodec] =
|
|
useState<BrowserVideoCodec | null>(null);
|
|
const shareGenerationRef = useRef<string | null>(null);
|
|
const [phase, setPhase] = useState<HostPhase>("idle");
|
|
const [signalStatus, setSignalStatus] =
|
|
useState<SignalConnectionState>("offline");
|
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
|
const [details, setDetails] = useState<CaptureDetails | null>(null);
|
|
const [room, setRoom] = useState<HostRoomState | null>(() =>
|
|
hostRoomFromStored(readHostRoom()),
|
|
);
|
|
const roomRef = useRef(room);
|
|
useEffect(() => {
|
|
roomRef.current = room;
|
|
}, [room]);
|
|
const [creationProfile, setCreationProfile] =
|
|
useState<HostCreationProfile>(readCreationProfile);
|
|
const creationProfileRef = useRef(creationProfile);
|
|
const [roomMutation, setRoomMutation] = useState<
|
|
"access" | "replacement" | null
|
|
>(null);
|
|
const roomMutating = roomMutation !== null;
|
|
const [viewerPasswordEnabled, setViewerPasswordEnabled] = useState(
|
|
creationProfile.roomPassword !== null,
|
|
);
|
|
useEffect(() => {
|
|
creationProfileRef.current = creationProfile;
|
|
}, [creationProfile]);
|
|
const [viewerPasswordDraft, setViewerPasswordDraft] = useState(
|
|
creationProfile.roomPassword ?? "",
|
|
);
|
|
const [viewerPasswordVisible, setViewerPasswordVisible] = useState(false);
|
|
const [maxViewers, setMaxViewers] = useState<number | null>(null);
|
|
const [peerSnapshots, setPeerSnapshots] = useState<Map<string, PeerSnapshot>>(
|
|
() => new Map(),
|
|
);
|
|
const [participantPresence, setParticipantPresence] = useState<
|
|
ParticipantPresenceEntry[]
|
|
>([]);
|
|
const [displayName, setDisplayName] = useState(() => readDisplayName());
|
|
const [displayNameDraft, setDisplayNameDraft] = useState(displayName);
|
|
const [displayNameError, setDisplayNameError] = useState<string | null>(null);
|
|
const [editingDisplayName, setEditingDisplayName] = useState(false);
|
|
const [viewerQualityEvidence, setViewerQualityEvidence] = useState<
|
|
Map<string, ViewerQualityEvidencePresentation>
|
|
>(() => new Map());
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const [switchingSource, setSwitchingSource] = useState(false);
|
|
const [changingQuality, setChangingQuality] = useState(false);
|
|
const [sharingPaused, setSharingPaused] = useState(false);
|
|
const [localPreviewPaused, setLocalPreviewPaused] = useState(false);
|
|
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
|
|
const [showTopology, setShowTopology] = useState(false);
|
|
const [joiningRoom, setJoiningRoom] = useState(false);
|
|
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const signalRef = useRef<SignalingClient | null>(null);
|
|
const displayNameRef = useRef(displayName);
|
|
const hostClientIdRef = useRef<string | null>(null);
|
|
const iceConfigRef = useRef<IceConfig | null>(null);
|
|
const peersRef = useRef(new Map<string, HostPeer>());
|
|
const retiredConnectionsRef = useRef(new Map<string, string>());
|
|
const hostProvisionalChildRef = useRef<HostProvisionalChild | null>(null);
|
|
const activeHostChildPeerIdsRef = useRef<string[]>([]);
|
|
const endpointMediaCopyCapacityRef = useRef(MAX_ENDPOINT_MEDIA_CHILDREN);
|
|
const hostPeerIdRef = useRef<string | null>(null);
|
|
const viewerQualityEvidenceRef = useRef(
|
|
new Map<string, ViewerQualityEvidencePresentation>(),
|
|
);
|
|
const viewerQualityEvidenceTimersRef = useRef(
|
|
new Map<string, number>(),
|
|
);
|
|
const peerAssistedRef = useRef(false);
|
|
const activeRouteRevisionRef = useRef(0);
|
|
const generationRef = useRef(0);
|
|
const activeGenerationRef = useRef<number | null>(null);
|
|
const sourceSwitchRef = useRef<object | null>(null);
|
|
const qualityChangeRef = useRef<object | null>(null);
|
|
const pendingQualityChangeRef = useRef<QualitySettings | null>(null);
|
|
const qualitySettingsRef = useRef<QualitySettings>(DEFAULT_QUALITY_SETTINGS);
|
|
const routePolicyRef = useRef<RoutePolicy>(DEFAULT_ROUTE_POLICY);
|
|
const advancedQualityRef = useRef<QualitySettings>(advancedQuality);
|
|
const videoCodecModeRef = useRef<BrowserVideoCodecMode>(videoCodecMode);
|
|
const roomMutationRef = useRef<object | null>(null);
|
|
const videoCodecRef = useRef<BrowserVideoCodecPreference>(
|
|
VP8_ONLY_VIDEO_CODEC,
|
|
);
|
|
const codecProbeAbortRef = useRef<AbortController | null>(null);
|
|
const sharingPausedRef = useRef(false);
|
|
const retiringStreamRef = useRef<MediaStream | null>(null);
|
|
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
|
|
const sfuStandbyPrewarmerRef = useRef<SfuStandbyPrewarmer | null>(null);
|
|
|
|
const mediaViewers = useMemo(
|
|
() => Array.from(peerSnapshots.values()),
|
|
[peerSnapshots],
|
|
);
|
|
const { host: labeledHostPresence, viewers } = useMemo(
|
|
() => labelParticipantSnapshot(participantPresence),
|
|
[participantPresence],
|
|
);
|
|
const hostPresence = useMemo(
|
|
() =>
|
|
participantPresence.find(
|
|
(participant): participant is Extract<
|
|
ParticipantPresenceEntry,
|
|
{ role: "host" }
|
|
> => participant.role === "host",
|
|
) ?? null,
|
|
[participantPresence],
|
|
);
|
|
const selectedQualityProfileId = useMemo(
|
|
() => matchingQualityProfileId(qualitySettings),
|
|
[qualitySettings],
|
|
);
|
|
const qualityLimitation = useMemo(
|
|
() => qualityLimitationSummary(mediaViewers),
|
|
[mediaViewers],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = stream;
|
|
}
|
|
}, [stream]);
|
|
|
|
useEffect(() => {
|
|
let senderQualitySuspended = false;
|
|
const resetSenderQualityAuthority = () => {
|
|
if (senderQualitySuspended) {
|
|
return;
|
|
}
|
|
senderQualitySuspended = true;
|
|
invalidateSenderQualityEvidence();
|
|
if (routePolicyRef.current.topologyOptimization) {
|
|
signalRef.current?.send({ type: "reset-sender-quality" });
|
|
}
|
|
};
|
|
const syncPreviewPlayback = () => {
|
|
const video = videoRef.current;
|
|
if (!video) {
|
|
return;
|
|
}
|
|
const shouldPause = shouldPauseLocalPreview(
|
|
document.visibilityState,
|
|
document.hasFocus(),
|
|
);
|
|
if (document.visibilityState !== "visible") {
|
|
resetSenderQualityAuthority();
|
|
} else {
|
|
senderQualitySuspended = false;
|
|
}
|
|
setLocalPreviewPaused(shouldPause);
|
|
if (shouldPause) {
|
|
video.pause();
|
|
return;
|
|
}
|
|
if (streamRef.current) {
|
|
void video.play().catch(() => undefined);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("visibilitychange", syncPreviewPlayback);
|
|
window.addEventListener("blur", syncPreviewPlayback);
|
|
window.addEventListener("focus", syncPreviewPlayback);
|
|
window.addEventListener("pagehide", resetSenderQualityAuthority);
|
|
syncPreviewPlayback();
|
|
return () => {
|
|
document.removeEventListener("visibilitychange", syncPreviewPlayback);
|
|
window.removeEventListener("blur", syncPreviewPlayback);
|
|
window.removeEventListener("focus", syncPreviewPlayback);
|
|
window.removeEventListener("pagehide", resetSenderQualityAuthority);
|
|
};
|
|
}, [stream]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
activeGenerationRef.current = null;
|
|
generationRef.current += 1;
|
|
sourceSwitchRef.current = null;
|
|
qualityChangeRef.current = null;
|
|
pendingQualityChangeRef.current = null;
|
|
codecProbeAbortRef.current?.abort();
|
|
codecProbeAbortRef.current = null;
|
|
signalRef.current?.stop();
|
|
peersRef.current.forEach((peer) => peer.dispose());
|
|
peersRef.current.clear();
|
|
retiredConnectionsRef.current.clear();
|
|
hostProvisionalChildRef.current?.discard();
|
|
hostProvisionalChildRef.current = null;
|
|
activeHostChildPeerIdsRef.current = [];
|
|
endpointMediaCopyCapacityRef.current = MAX_ENDPOINT_MEDIA_CHILDREN;
|
|
hostPeerIdRef.current = null;
|
|
viewerQualityEvidenceTimersRef.current.forEach((timer) =>
|
|
window.clearTimeout(timer),
|
|
);
|
|
viewerQualityEvidenceTimersRef.current.clear();
|
|
viewerQualityEvidenceRef.current.clear();
|
|
activeRouteRevisionRef.current = 0;
|
|
void hostSfuRouteRef.current?.disconnect();
|
|
hostSfuRouteRef.current = null;
|
|
sfuStandbyPrewarmerRef.current?.dispose();
|
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
streamRef.current = null;
|
|
retiringStreamRef.current = null;
|
|
},
|
|
[],
|
|
);
|
|
|
|
function isCurrentGeneration(generation: number): boolean {
|
|
return (
|
|
generationRef.current === generation &&
|
|
activeGenerationRef.current === generation
|
|
);
|
|
}
|
|
|
|
async function resolveStreamVideoCodec(
|
|
stream: MediaStream,
|
|
): Promise<BrowserVideoCodecPreference> {
|
|
codecProbeAbortRef.current?.abort();
|
|
const mode = videoCodecModeRef.current;
|
|
if (mode !== "auto") {
|
|
codecProbeAbortRef.current = null;
|
|
return manualVideoCodecPreference(mode);
|
|
}
|
|
const controller = new AbortController();
|
|
codecProbeAbortRef.current = controller;
|
|
const track = stream.getVideoTracks()[0];
|
|
try {
|
|
const codec = track
|
|
? await preferredVideoCodecForTrack(
|
|
track,
|
|
qualitySettingsRef.current,
|
|
controller.signal,
|
|
)
|
|
: "vp8";
|
|
return automaticVideoCodecPreference(codec);
|
|
} finally {
|
|
if (codecProbeAbortRef.current === controller) {
|
|
codecProbeAbortRef.current = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function ensureHostSfuRoute(generation: number): HostSfuRoute {
|
|
const existing = hostSfuRouteRef.current;
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
let route: HostSfuRoute;
|
|
route = new HostSfuRoute({
|
|
getStream: () => streamRef.current,
|
|
getProfile: () => qualitySettingsRef.current,
|
|
getVideoCodec: () => videoCodecRef.current.primary,
|
|
reconcileChildren: (childPeerIds) => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
hostSfuRouteRef.current === route
|
|
) {
|
|
reconcileHostChildren(childPeerIds, generation);
|
|
}
|
|
},
|
|
send: (message) =>
|
|
isCurrentGeneration(generation) && hostSfuRouteRef.current === route
|
|
? signalRef.current?.send(message) === true
|
|
: false,
|
|
onSenderUpdate: (metrics, revision, publicationGeneration) => {
|
|
if (
|
|
!isCurrentGeneration(generation) ||
|
|
!routePolicyRef.current.topologyOptimization ||
|
|
document.visibilityState !== "visible"
|
|
) {
|
|
return;
|
|
}
|
|
const evidence = sfuPublisherQualityEvidenceFromMetrics(
|
|
metrics,
|
|
revision,
|
|
publicationGeneration,
|
|
);
|
|
if (evidence) {
|
|
signalRef.current?.send(evidence);
|
|
}
|
|
},
|
|
});
|
|
hostSfuRouteRef.current = route;
|
|
return route;
|
|
}
|
|
|
|
function clearHostSfuRoute(): void {
|
|
const route = hostSfuRouteRef.current;
|
|
hostSfuRouteRef.current = null;
|
|
sfuStandbyPrewarmerRef.current?.setUrl(null);
|
|
void route?.disconnect();
|
|
}
|
|
|
|
function setSfuStandbyUrl(url: string | null | undefined): void {
|
|
if (!url) {
|
|
sfuStandbyPrewarmerRef.current?.setUrl(null);
|
|
return;
|
|
}
|
|
sfuStandbyPrewarmerRef.current ??= new SfuStandbyPrewarmer();
|
|
sfuStandbyPrewarmerRef.current.setUrl(url);
|
|
}
|
|
|
|
function showHostSfuQualityWarning(
|
|
route: HostSfuRoute,
|
|
generation: number,
|
|
): void {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
hostSfuRouteRef.current === route
|
|
) {
|
|
const warning = route.getQualityWarning();
|
|
if (warning) {
|
|
setNotice(warning);
|
|
}
|
|
}
|
|
}
|
|
|
|
function disposeResources(notifyServer: boolean): void {
|
|
sourceSwitchRef.current = null;
|
|
qualityChangeRef.current = null;
|
|
pendingQualityChangeRef.current = null;
|
|
codecProbeAbortRef.current?.abort();
|
|
codecProbeAbortRef.current = null;
|
|
videoCodecRef.current = VP8_ONLY_VIDEO_CODEC;
|
|
setResolvedVideoCodec(null);
|
|
const signal = signalRef.current;
|
|
if (signal) {
|
|
if (notifyServer) {
|
|
const shareGeneration = shareGenerationRef.current;
|
|
if (shareGeneration) {
|
|
signal.send({ type: "stop-sharing", shareGeneration });
|
|
}
|
|
}
|
|
// Never carry a terminal message into a later authentication: the next
|
|
// sharing generation may already be reusing this room.
|
|
signal.stop();
|
|
}
|
|
signalRef.current = null;
|
|
shareGenerationRef.current = null;
|
|
peersRef.current.forEach((peer) => peer.dispose());
|
|
peersRef.current.clear();
|
|
retiredConnectionsRef.current.clear();
|
|
hostProvisionalChildRef.current?.discard();
|
|
hostProvisionalChildRef.current = null;
|
|
activeHostChildPeerIdsRef.current = [];
|
|
endpointMediaCopyCapacityRef.current = MAX_ENDPOINT_MEDIA_CHILDREN;
|
|
hostPeerIdRef.current = null;
|
|
void hostSfuRouteRef.current?.disconnect();
|
|
hostSfuRouteRef.current = null;
|
|
sfuStandbyPrewarmerRef.current?.setUrl(null);
|
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
streamRef.current = null;
|
|
retiringStreamRef.current = null;
|
|
iceConfigRef.current = null;
|
|
peerAssistedRef.current = false;
|
|
setStream(null);
|
|
setDetails(null);
|
|
setMaxViewers(null);
|
|
setPeerSnapshots(new Map());
|
|
setParticipantPresence([]);
|
|
viewerQualityEvidenceTimersRef.current.forEach((timer) =>
|
|
window.clearTimeout(timer),
|
|
);
|
|
viewerQualityEvidenceTimersRef.current.clear();
|
|
viewerQualityEvidenceRef.current = new Map();
|
|
setViewerQualityEvidence(new Map());
|
|
activeRouteRevisionRef.current = 0;
|
|
setSignalStatus("offline");
|
|
setSwitchingSource(false);
|
|
setChangingQuality(false);
|
|
sharingPausedRef.current = false;
|
|
setSharingPaused(false);
|
|
}
|
|
|
|
function isCurrentRoomAuthority(expected: HostRoomState): boolean {
|
|
const current = roomRef.current;
|
|
return (
|
|
current?.roomId === expected.roomId &&
|
|
current.hostToken === expected.hostToken
|
|
);
|
|
}
|
|
|
|
function forgetRoom(expected?: HostRoomState): boolean {
|
|
if (expected && !isCurrentRoomAuthority(expected)) {
|
|
return false;
|
|
}
|
|
clearHostRoom();
|
|
roomRef.current = null;
|
|
setRoom(null);
|
|
setCopied(false);
|
|
setViewerPasswordDraft(creationProfileRef.current.roomPassword ?? "");
|
|
setViewerPasswordVisible(false);
|
|
return true;
|
|
}
|
|
|
|
function beginRoomMutation(
|
|
kind: "access" | "replacement",
|
|
): object | null {
|
|
if (roomMutationRef.current) {
|
|
return null;
|
|
}
|
|
const token = {};
|
|
roomMutationRef.current = token;
|
|
setRoomMutation(kind);
|
|
return token;
|
|
}
|
|
|
|
function finishRoomMutation(token: object): void {
|
|
if (roomMutationRef.current !== token) {
|
|
return;
|
|
}
|
|
roomMutationRef.current = null;
|
|
setRoomMutation(null);
|
|
}
|
|
|
|
function endSharing(message: string, notifyServer = true): void {
|
|
const generation = activeGenerationRef.current;
|
|
if (generation === null || generationRef.current !== generation) {
|
|
return;
|
|
}
|
|
activeGenerationRef.current = null;
|
|
generationRef.current += 1;
|
|
const currentRoom = roomRef.current;
|
|
if (notifyServer && currentRoom) {
|
|
writePreferredRoom(currentRoom.roomId);
|
|
}
|
|
disposeResources(notifyServer);
|
|
setNotice(message);
|
|
setPhase("ended");
|
|
}
|
|
|
|
async function replaceCurrentRoom(): Promise<void> {
|
|
const activeRoom = roomRef.current;
|
|
if (!activeRoom || roomMutationRef.current || phase === "starting") {
|
|
return;
|
|
}
|
|
const mutation = beginRoomMutation("replacement");
|
|
if (!mutation) {
|
|
return;
|
|
}
|
|
const wasSharing = activeGenerationRef.current !== null;
|
|
try {
|
|
const profile = creationProfileRef.current;
|
|
const response = await replaceOwnedRoom(
|
|
activeRoom.roomId,
|
|
activeRoom.hostToken,
|
|
profile.codeEntryPolicy,
|
|
profile.roomPassword,
|
|
);
|
|
const replacement = hostRoomFromCreated(response);
|
|
if (wasSharing) {
|
|
endSharing("房间号已更换", false);
|
|
}
|
|
replaceViewerInvite(activeRoom.roomId, null);
|
|
writeHostRoom(replacement);
|
|
writePreferredRoom(replacement.roomId);
|
|
roomRef.current = replacement;
|
|
setRoom(replacement);
|
|
setCopied(false);
|
|
setViewerPasswordEnabled(profile.roomPassword !== null);
|
|
setViewerPasswordDraft(profile.roomPassword ?? "");
|
|
setViewerPasswordVisible(false);
|
|
setNotice(null);
|
|
setPhase(wasSharing ? "ended" : "idle");
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 404) {
|
|
if (forgetRoom(activeRoom)) {
|
|
endSharing("房间已失效,再次点击将创建新房", false);
|
|
}
|
|
}
|
|
setNotice(readableError(error, "room"));
|
|
} finally {
|
|
finishRoomMutation(mutation);
|
|
}
|
|
}
|
|
|
|
function updatePeerSnapshot(snapshot: PeerSnapshot): void {
|
|
const presentation = viewerQualityEvidenceRef.current.get(snapshot.peerId);
|
|
if (presentation) {
|
|
const reconciled = reconcileViewerQualityEvidencePresentation(
|
|
presentation,
|
|
snapshot,
|
|
);
|
|
if (reconciled !== presentation) {
|
|
commitViewerQualityEvidence(snapshot.peerId, reconciled);
|
|
}
|
|
}
|
|
setPeerSnapshots((current) => {
|
|
const next = new Map(current);
|
|
next.set(snapshot.peerId, snapshot);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function commitViewerQualityEvidence(
|
|
peerId: string,
|
|
presentation: ViewerQualityEvidencePresentation | null,
|
|
): void {
|
|
const timer = viewerQualityEvidenceTimersRef.current.get(peerId);
|
|
if (timer !== undefined) {
|
|
window.clearTimeout(timer);
|
|
viewerQualityEvidenceTimersRef.current.delete(peerId);
|
|
}
|
|
const current = viewerQualityEvidenceRef.current.get(peerId);
|
|
if (presentation === null && current === undefined) {
|
|
return;
|
|
}
|
|
if (current !== presentation) {
|
|
const next = new Map(viewerQualityEvidenceRef.current);
|
|
if (presentation === null) {
|
|
next.delete(peerId);
|
|
} else {
|
|
next.set(peerId, presentation);
|
|
}
|
|
viewerQualityEvidenceRef.current = next;
|
|
setViewerQualityEvidence(next);
|
|
}
|
|
if (presentation === null) {
|
|
return;
|
|
}
|
|
|
|
const nowMs = Date.now();
|
|
const expiryAt = nextViewerQualityEvidencePresentationExpiryAt(
|
|
presentation,
|
|
nowMs,
|
|
);
|
|
if (expiryAt === null) {
|
|
return;
|
|
}
|
|
const expected = presentation;
|
|
const nextTimer = window.setTimeout(() => {
|
|
if (viewerQualityEvidenceRef.current.get(peerId) !== expected) {
|
|
return;
|
|
}
|
|
commitViewerQualityEvidence(
|
|
peerId,
|
|
refreshViewerQualityEvidencePresentation(expected),
|
|
);
|
|
}, Math.max(0, expiryAt - nowMs));
|
|
viewerQualityEvidenceTimersRef.current.set(peerId, nextTimer);
|
|
}
|
|
|
|
function clearViewerQualityEvidence(peerId: string): void {
|
|
commitViewerQualityEvidence(peerId, null);
|
|
}
|
|
|
|
function acceptViewerQualityEvidence(evidence: ViewerQualityEvidence): void {
|
|
const peer = peersRef.current.get(evidence.viewerPeerId);
|
|
const directSnapshot = peer?.getSnapshot() ?? null;
|
|
const evidenceSource = classifyHostViewerQualityEvidence(
|
|
evidence,
|
|
hostPeerIdRef.current,
|
|
activeRouteRevisionRef.current,
|
|
directSnapshot,
|
|
);
|
|
if (
|
|
!evidenceSource ||
|
|
(evidenceSource === "peer-relayed" && !peerAssistedRef.current)
|
|
) {
|
|
return;
|
|
}
|
|
commitViewerQualityEvidence(
|
|
evidence.viewerPeerId,
|
|
presentViewerQualityEvidence(
|
|
viewerQualityEvidenceRef.current.get(evidence.viewerPeerId) ?? null,
|
|
evidence,
|
|
),
|
|
);
|
|
}
|
|
|
|
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 commitQuality(settings: QualitySettings): void {
|
|
qualitySettingsRef.current = settings;
|
|
setQualitySettings(settings);
|
|
const visibleSettings = pendingQualityChangeRef.current ?? settings;
|
|
advancedQualityRef.current = visibleSettings;
|
|
setAdvancedQuality(visibleSettings);
|
|
}
|
|
|
|
function changeScreenAudioQuality(
|
|
screenAudioQuality: ScreenAudioQuality,
|
|
): void {
|
|
if (phase === "starting") {
|
|
return;
|
|
}
|
|
const next = { ...advancedQualityRef.current, screenAudioQuality };
|
|
void changeQuality(next);
|
|
}
|
|
|
|
function changeVideoCodecMode(mode: BrowserVideoCodecMode): void {
|
|
if (phase === "starting" || phase === "live") {
|
|
return;
|
|
}
|
|
videoCodecModeRef.current = mode;
|
|
setVideoCodecMode(mode);
|
|
setResolvedVideoCodec(null);
|
|
}
|
|
|
|
function changeRoutePolicy(patch: Partial<RoutePolicy>): void {
|
|
if (phase === "starting" || phase === "live") {
|
|
return;
|
|
}
|
|
const next = { ...routePolicyRef.current, ...patch };
|
|
routePolicyRef.current = next;
|
|
setRoutePolicy(next);
|
|
}
|
|
|
|
function changeAdvancedQuality(
|
|
patch: Partial<QualitySettings>,
|
|
): void {
|
|
const next = { ...advancedQualityRef.current, ...patch };
|
|
advancedQualityRef.current = next;
|
|
setAdvancedQuality(next);
|
|
void changeQuality(next);
|
|
}
|
|
|
|
async function changeQuality(nextProfile: QualitySettings): Promise<void> {
|
|
advancedQualityRef.current = nextProfile;
|
|
setAdvancedQuality(nextProfile);
|
|
if (phase !== "live") {
|
|
if (qualitySettingsEqual(qualitySettingsRef.current, nextProfile)) {
|
|
return;
|
|
}
|
|
commitQuality(nextProfile);
|
|
return;
|
|
}
|
|
if (qualityChangeRef.current) {
|
|
pendingQualityChangeRef.current = nextProfile;
|
|
return;
|
|
}
|
|
|
|
const previousProfile = qualitySettingsRef.current;
|
|
if (qualitySettingsEqual(previousProfile, nextProfile)) {
|
|
return;
|
|
}
|
|
|
|
const generation = activeGenerationRef.current;
|
|
const activeStream = streamRef.current;
|
|
if (
|
|
generation === null ||
|
|
!activeStream ||
|
|
!isCurrentGeneration(generation) ||
|
|
sourceSwitchRef.current
|
|
) {
|
|
return;
|
|
}
|
|
const token = {};
|
|
qualityChangeRef.current = token;
|
|
setChangingQuality(true);
|
|
setNotice(null);
|
|
const videoChanged = !videoQualitySettingsEqual(
|
|
previousProfile,
|
|
nextProfile,
|
|
);
|
|
const captureChanged =
|
|
previousProfile.resolution !== nextProfile.resolution ||
|
|
previousProfile.maxFramerate !== nextProfile.maxFramerate;
|
|
const audioChanged =
|
|
resolveScreenAudioQuality(previousProfile.screenAudioQuality) !==
|
|
resolveScreenAudioQuality(nextProfile.screenAudioQuality);
|
|
try {
|
|
if (captureChanged) {
|
|
await applyCaptureProfile(activeStream, nextProfile);
|
|
}
|
|
if (
|
|
!isCurrentGeneration(generation) ||
|
|
qualityChangeRef.current !== token ||
|
|
streamRef.current !== activeStream
|
|
) {
|
|
return;
|
|
}
|
|
|
|
commitQuality(nextProfile);
|
|
if (captureChanged) {
|
|
setDetails(captureDetails(activeStream));
|
|
}
|
|
if (peerAssistedRef.current) {
|
|
signalRef.current?.setHostQualitySettings(nextProfile);
|
|
}
|
|
const activeSfuRoute = hostSfuRouteRef.current;
|
|
const [results, sfuUpdated] = await Promise.all([
|
|
Promise.all(
|
|
[
|
|
...[...peersRef.current.values()].map((peer) =>
|
|
peer.updateProfile(nextProfile),
|
|
),
|
|
...(hostProvisionalChildRef.current
|
|
? [hostProvisionalChildRef.current.updateProfile(nextProfile)]
|
|
: []),
|
|
],
|
|
),
|
|
activeSfuRoute?.updateProfile(nextProfile) ??
|
|
Promise.resolve(true),
|
|
]);
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
qualityChangeRef.current === token
|
|
) {
|
|
const failed = results.filter((updated) => !updated).length;
|
|
const sfuWarning =
|
|
hostSfuRouteRef.current === activeSfuRoute
|
|
? (activeSfuRoute?.getQualityWarning() ?? null)
|
|
: null;
|
|
const connectionWarning =
|
|
failed > 0 || !sfuUpdated
|
|
? "分享设置已更新,但部分观看连接未能应用新参数"
|
|
: null;
|
|
const warning = sfuWarning ?? connectionWarning;
|
|
const successNotice =
|
|
audioChanged && !videoChanged
|
|
? `音频质量已切换为 ${SCREEN_AUDIO_QUALITY_LABELS[resolveScreenAudioQuality(nextProfile.screenAudioQuality)]}`
|
|
: videoChanged && audioChanged
|
|
? "分享设置已应用"
|
|
: `画质已切换为 ${qualitySettingsLabel(nextProfile)}`;
|
|
setNotice(warning || successNotice);
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
qualityChangeRef.current === token
|
|
) {
|
|
if (pendingQualityChangeRef.current === null) {
|
|
advancedQualityRef.current = qualitySettingsRef.current;
|
|
setAdvancedQuality(qualitySettingsRef.current);
|
|
}
|
|
setNotice(readableError(error, "quality"));
|
|
}
|
|
} finally {
|
|
if (qualityChangeRef.current === token) {
|
|
qualityChangeRef.current = null;
|
|
const pending = pendingQualityChangeRef.current;
|
|
pendingQualityChangeRef.current = null;
|
|
if (
|
|
pending &&
|
|
isCurrentGeneration(generation) &&
|
|
!qualitySettingsEqual(qualitySettingsRef.current, pending)
|
|
) {
|
|
void changeQuality(pending);
|
|
} else {
|
|
setChangingQuality(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleSharingPause(): void {
|
|
const activeStream = streamRef.current;
|
|
if (phase !== "live" || !activeStream) {
|
|
return;
|
|
}
|
|
if (sharingPausedRef.current) {
|
|
if (!setMediaPaused(activeStream, false)) {
|
|
setNotice("当前分享没有可恢复的媒体轨道");
|
|
return;
|
|
}
|
|
hostSfuRouteRef.current?.setPaused(false);
|
|
if (signalRef.current?.setSharingPaused(false) !== true) {
|
|
setMediaPaused(activeStream, true);
|
|
hostSfuRouteRef.current?.setPaused(true);
|
|
signalRef.current?.confirmSharingPaused();
|
|
setNotice("服务器连接正在恢复,分享仍保持暂停");
|
|
return;
|
|
}
|
|
sharingPausedRef.current = false;
|
|
setSharingPaused(false);
|
|
setNotice("音视频分享已恢复");
|
|
return;
|
|
}
|
|
if (!setMediaPaused(activeStream, true)) {
|
|
setNotice("当前分享没有可暂停的媒体轨道");
|
|
return;
|
|
}
|
|
sharingPausedRef.current = true;
|
|
setSharingPaused(true);
|
|
hostSfuRouteRef.current?.setPaused(true);
|
|
discardPreparedHostChild();
|
|
setNotice(
|
|
signalRef.current?.setSharingPaused(true) === true
|
|
? "音视频分享已暂停"
|
|
: "服务器连接正在恢复,分享保持暂停",
|
|
);
|
|
}
|
|
|
|
function removePeer(peerId: string): void {
|
|
clearViewerQualityEvidence(peerId);
|
|
const peer = peersRef.current.get(peerId);
|
|
if (peer) {
|
|
retiredConnectionsRef.current.set(peerId, peer.connectionId);
|
|
peer.dispose();
|
|
}
|
|
peersRef.current.delete(peerId);
|
|
setPeerSnapshots((current) => {
|
|
const next = new Map(current);
|
|
next.delete(peerId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function hostChildIsAssigned(peerId: string): boolean {
|
|
return activeHostChildPeerIdsRef.current.includes(peerId);
|
|
}
|
|
|
|
function discardPreparedHostChild(): void {
|
|
const provisional = hostProvisionalChildRef.current;
|
|
hostProvisionalChildRef.current = null;
|
|
provisional?.discard();
|
|
}
|
|
|
|
function reportSenderQuality(
|
|
snapshot: PeerSnapshot,
|
|
revision: number,
|
|
): void {
|
|
if (
|
|
!routePolicyRef.current.topologyOptimization ||
|
|
document.visibilityState !== "visible"
|
|
) {
|
|
return;
|
|
}
|
|
const evidence = senderQualityEvidenceFromSnapshot(snapshot, revision);
|
|
if (evidence) {
|
|
signalRef.current?.send(evidence);
|
|
}
|
|
}
|
|
|
|
function prepareHostChild(
|
|
revision: number,
|
|
assignment: HostRouteAssignment,
|
|
candidate: PreparedRouteCandidate,
|
|
generation: number,
|
|
): boolean {
|
|
const stream = streamRef.current;
|
|
const iceConfig = iceConfigRef.current;
|
|
const signal = signalRef.current;
|
|
if (
|
|
!isCurrentGeneration(generation) ||
|
|
!stream ||
|
|
!iceConfig ||
|
|
!signal
|
|
) {
|
|
discardPreparedHostChild();
|
|
return false;
|
|
}
|
|
hostProvisionalChildRef.current ??= new HostProvisionalChild({
|
|
sendSignal: (targetPeerId, payload) =>
|
|
isCurrentGeneration(generation) && signalRef.current === signal
|
|
? signal.send({ type: "signal", targetPeerId, payload })
|
|
: false,
|
|
activeConnectionId: (peerId) =>
|
|
peersRef.current.get(peerId)?.connectionId ?? null,
|
|
onPromotedStreamFailure: (peer) => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
peersRef.current.get(peer.peerId) === peer
|
|
) {
|
|
removePeer(peer.peerId);
|
|
}
|
|
},
|
|
onPromotedUpdate: (peer, snapshot) => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
peersRef.current.get(peer.peerId) === peer
|
|
) {
|
|
updatePeerSnapshot(snapshot);
|
|
reportSenderQuality(snapshot, activeRouteRevisionRef.current);
|
|
}
|
|
},
|
|
onPreparedUpdate: (_peer, snapshot, revision) => {
|
|
if (isCurrentGeneration(generation)) {
|
|
reportSenderQuality(snapshot, revision);
|
|
}
|
|
},
|
|
});
|
|
return hostProvisionalChildRef.current.prepare({
|
|
revision,
|
|
assignment,
|
|
candidate,
|
|
activeChildPeerIds: activeHostChildPeerIdsRef.current,
|
|
maxMediaEdges: endpointMediaCopyCapacityRef.current,
|
|
iceConfig,
|
|
stream,
|
|
profile: qualitySettingsRef.current,
|
|
videoCodec: videoCodecRef.current,
|
|
});
|
|
}
|
|
|
|
function activatePreparedHostChild(
|
|
revision: number,
|
|
assignment: HostRouteAssignment,
|
|
): void {
|
|
const activeChildPeerIds = activeHostChildPeerIdsRef.current;
|
|
const provisional = hostProvisionalChildRef.current;
|
|
const activation = provisional?.activate({
|
|
revision,
|
|
assignment,
|
|
activeChildPeerIds,
|
|
maxMediaEdges: endpointMediaCopyCapacityRef.current,
|
|
}) ?? { kind: "ordinary" as const };
|
|
if (activation.kind === "promote") {
|
|
hostProvisionalChildRef.current = null;
|
|
const previous = peersRef.current.get(activation.peerId);
|
|
peersRef.current.set(activation.peerId, activation.peer);
|
|
previous?.dispose();
|
|
updatePeerSnapshot(activation.peer.getSnapshot());
|
|
} else {
|
|
discardPreparedHostChild();
|
|
}
|
|
}
|
|
|
|
async function startPeer(
|
|
peerId: string,
|
|
generation: number,
|
|
attempt = 0,
|
|
): Promise<void> {
|
|
if (!isCurrentGeneration(generation) || !hostChildIsAssigned(peerId)) {
|
|
return;
|
|
}
|
|
// Preserve healthy media across control reconnects, but replace a stalled
|
|
// negotiation whose offer or answer may have been lost with the WebSocket.
|
|
const existing = peersRef.current.get(peerId);
|
|
if (existing) {
|
|
if (existing.isConnected()) {
|
|
return;
|
|
}
|
|
removePeer(peerId);
|
|
}
|
|
const activeStream = streamRef.current;
|
|
const iceConfig = iceConfigRef.current;
|
|
const signal = signalRef.current;
|
|
if (!activeStream || !iceConfig || !signal) {
|
|
return;
|
|
}
|
|
|
|
let peer: HostPeer;
|
|
peer = new HostPeer(
|
|
peerId,
|
|
iceConfig,
|
|
activeStream,
|
|
qualitySettingsRef.current,
|
|
{
|
|
sendSignal: (targetPeerId, payload) =>
|
|
targetPeerId === peerId &&
|
|
isCurrentGeneration(generation) &&
|
|
hostChildIsAssigned(peerId) &&
|
|
peersRef.current.get(peerId) === peer &&
|
|
signalRef.current === signal
|
|
? signal.send({ type: "signal", targetPeerId, payload })
|
|
: false,
|
|
onUpdate: (snapshot) => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
peersRef.current.get(peerId) === peer
|
|
) {
|
|
updatePeerSnapshot(snapshot);
|
|
reportSenderQuality(snapshot, activeRouteRevisionRef.current);
|
|
}
|
|
},
|
|
},
|
|
videoCodecRef.current,
|
|
);
|
|
peersRef.current.set(peerId, peer);
|
|
let started: boolean;
|
|
try {
|
|
started = await peer.start();
|
|
} catch (error) {
|
|
if (peersRef.current.get(peerId) === peer) {
|
|
removePeer(peerId);
|
|
}
|
|
throw error;
|
|
}
|
|
if (started || peersRef.current.get(peerId) !== peer) {
|
|
return;
|
|
}
|
|
|
|
removePeer(peerId);
|
|
if (peerAssistedRef.current || attempt >= 1) {
|
|
return;
|
|
}
|
|
window.setTimeout(() => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
!peerAssistedRef.current &&
|
|
hostChildIsAssigned(peerId) &&
|
|
!peersRef.current.has(peerId)
|
|
) {
|
|
void startPeer(peerId, generation, attempt + 1).catch(
|
|
(error: unknown) => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
hostChildIsAssigned(peerId)
|
|
) {
|
|
setNotice(readableError(error, "connection"));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
async function recoverPeer(
|
|
peerId: string,
|
|
connectionId: string,
|
|
rebuild: boolean,
|
|
generation: number,
|
|
): Promise<void> {
|
|
if (!hostChildIsAssigned(peerId)) {
|
|
return;
|
|
}
|
|
const peer = peersRef.current.get(peerId);
|
|
if (rebuild) {
|
|
if (peer && peer.connectionId !== connectionId) {
|
|
return;
|
|
}
|
|
if (peer) {
|
|
removePeer(peerId);
|
|
}
|
|
await startPeer(peerId, generation);
|
|
return;
|
|
}
|
|
if (
|
|
!isCurrentGeneration(generation) ||
|
|
!peer ||
|
|
peer.connectionId !== connectionId
|
|
) {
|
|
return;
|
|
}
|
|
if (await peer.restartIce()) {
|
|
return;
|
|
}
|
|
if (!isCurrentGeneration(generation)) {
|
|
return;
|
|
}
|
|
if (peersRef.current.get(peerId) !== peer) {
|
|
return;
|
|
}
|
|
removePeer(peerId);
|
|
await startPeer(peerId, generation);
|
|
}
|
|
|
|
function reconcileHostChildren(
|
|
childPeerIds: string[],
|
|
generation: number,
|
|
): void {
|
|
activeHostChildPeerIdsRef.current = [
|
|
...new Set(childPeerIds),
|
|
].slice(0, endpointMediaCopyCapacityRef.current);
|
|
reconcileBoundedMediaChildren(
|
|
peersRef.current.keys(),
|
|
activeHostChildPeerIdsRef.current,
|
|
endpointMediaCopyCapacityRef.current,
|
|
removePeer,
|
|
(peerId) => {
|
|
void startPeer(peerId, generation).catch((error: unknown) => {
|
|
if (isCurrentGeneration(generation)) {
|
|
setNotice(readableError(error, "connection"));
|
|
}
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleSignalMessage(
|
|
message: ServerMessage,
|
|
generation: number,
|
|
activeRoom: HostRoomState,
|
|
reauthenticated: boolean,
|
|
pendingQualitySettings: QualitySettings | null,
|
|
): void {
|
|
if (!isCurrentGeneration(generation)) {
|
|
return;
|
|
}
|
|
if (message.type === "authenticated" && message.role === "host") {
|
|
discardPreparedHostChild();
|
|
hostPeerIdRef.current = message.peerId;
|
|
endpointMediaCopyCapacityRef.current = message.endpointMediaCopyCapacity;
|
|
const authenticatedProfile = {
|
|
codeEntryPolicy: message.codeEntryPolicy,
|
|
roomPassword: message.viewerPasswordEnabled
|
|
? creationProfileRef.current.roomPassword
|
|
: null,
|
|
};
|
|
saveCreationProfile(authenticatedProfile);
|
|
creationProfileRef.current = authenticatedProfile;
|
|
setCreationProfile(authenticatedProfile);
|
|
setViewerPasswordEnabled(message.viewerPasswordEnabled);
|
|
setViewerPasswordDraft(authenticatedProfile.roomPassword ?? "");
|
|
setViewerPasswordVisible(false);
|
|
setSfuStandbyUrl(
|
|
"sfuStandbyUrl" in message ? message.sfuStandbyUrl : null,
|
|
);
|
|
setMaxViewers(message.maxViewers);
|
|
setRoom((current) =>
|
|
mergeAuthenticatedHostRoom(
|
|
current,
|
|
activeRoom.roomId,
|
|
message.roomExpiresAt,
|
|
message.codeEntryPolicy,
|
|
),
|
|
);
|
|
if (
|
|
"mediaMode" in message &&
|
|
message.mediaMode === "peer-assisted"
|
|
) {
|
|
routePolicyRef.current = { ...message.routePolicy };
|
|
setRoutePolicy({ ...message.routePolicy });
|
|
const currentQualitySettings =
|
|
pendingQualitySettings ?? message.qualitySettings;
|
|
activeRouteRevisionRef.current = message.routeRevision;
|
|
peerAssistedRef.current = true;
|
|
if (reauthenticated) {
|
|
commitQuality(currentQualitySettings);
|
|
const endpointUpdates = [
|
|
...[...peersRef.current.values()].map((peer) =>
|
|
peer.updateProfile(currentQualitySettings),
|
|
),
|
|
...(hostProvisionalChildRef.current
|
|
? [
|
|
hostProvisionalChildRef.current.updateProfile(
|
|
currentQualitySettings,
|
|
),
|
|
]
|
|
: []),
|
|
];
|
|
void Promise.allSettled(endpointUpdates);
|
|
}
|
|
const route = ensureHostSfuRoute(generation);
|
|
void route
|
|
.resyncAuthoritative({
|
|
revision: message.routeRevision,
|
|
phase: "active",
|
|
assignment: message.routeAssignment,
|
|
})
|
|
.then(async () => {
|
|
if (reauthenticated && hostSfuRouteRef.current === route) {
|
|
await route.updateProfile(currentQualitySettings);
|
|
}
|
|
showHostSfuQualityWarning(route, generation);
|
|
});
|
|
return;
|
|
}
|
|
activeRouteRevisionRef.current = 0;
|
|
peerAssistedRef.current = false;
|
|
clearHostSfuRoute();
|
|
reconcileHostChildren(message.viewerPeerIds, generation);
|
|
return;
|
|
}
|
|
if (message.type === "viewer-presence") {
|
|
setParticipantPresence(message.viewers);
|
|
return;
|
|
}
|
|
if (message.type === "pause-sharing-source") {
|
|
if (message.shareGeneration !== shareGenerationRef.current) {
|
|
return;
|
|
}
|
|
const activeStream = streamRef.current;
|
|
if (activeStream) {
|
|
setMediaPaused(activeStream, true);
|
|
}
|
|
hostSfuRouteRef.current?.setPaused(true);
|
|
sharingPausedRef.current = true;
|
|
setSharingPaused(true);
|
|
signalRef.current?.confirmSharingPaused();
|
|
setNotice("分享仍保持暂停");
|
|
return;
|
|
}
|
|
if (message.type === "route-update") {
|
|
if (peerAssistedRef.current) {
|
|
const route = ensureHostSfuRoute(generation);
|
|
const accepted = route.accept(message);
|
|
if (accepted === "stale") return;
|
|
if (message.phase === "prepare") {
|
|
if (message.candidate.transport === "direct") {
|
|
prepareHostChild(
|
|
message.revision,
|
|
message.assignment,
|
|
message.candidate,
|
|
generation,
|
|
);
|
|
} else {
|
|
discardPreparedHostChild();
|
|
}
|
|
} else {
|
|
activatePreparedHostChild(message.revision, message.assignment);
|
|
}
|
|
if (
|
|
message.phase === "active" &&
|
|
message.revision !== activeRouteRevisionRef.current
|
|
) {
|
|
activeRouteRevisionRef.current = message.revision;
|
|
}
|
|
showHostSfuQualityWarning(route, generation);
|
|
}
|
|
return;
|
|
}
|
|
if (message.type === "viewer-quality-evidence") {
|
|
acceptViewerQualityEvidence(message);
|
|
return;
|
|
}
|
|
if (message.type === "sfu-config") {
|
|
if (peerAssistedRef.current) {
|
|
const route = ensureHostSfuRoute(generation);
|
|
void route
|
|
.acceptConfig(message)
|
|
.then(() => showHostSfuQualityWarning(route, generation));
|
|
}
|
|
return;
|
|
}
|
|
if (message.type === "media-assignment") {
|
|
if (peerAssistedRef.current && !hostSfuRouteRef.current) {
|
|
discardPreparedHostChild();
|
|
reconcileHostChildren(
|
|
message.mediaAssignment.childPeerIds,
|
|
generation,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (message.type === "peer-joined") {
|
|
if (peerAssistedRef.current) {
|
|
return;
|
|
}
|
|
reconcileHostChildren(
|
|
[...activeHostChildPeerIdsRef.current, message.peerId],
|
|
generation,
|
|
);
|
|
return;
|
|
}
|
|
if (message.type === "peer-waiting") {
|
|
if (!peerAssistedRef.current) {
|
|
reconcileHostChildren(
|
|
activeHostChildPeerIdsRef.current.filter(
|
|
(peerId) => peerId !== message.peerId,
|
|
),
|
|
generation,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (message.type === "peer-left") {
|
|
if (peerAssistedRef.current) {
|
|
return;
|
|
}
|
|
reconcileHostChildren(
|
|
activeHostChildPeerIdsRef.current.filter(
|
|
(peerId) => peerId !== message.peerId,
|
|
),
|
|
generation,
|
|
);
|
|
return;
|
|
}
|
|
if (message.type === "signal") {
|
|
if (
|
|
hostProvisionalChildRef.current?.acceptSignal(
|
|
message.fromPeerId,
|
|
message.payload,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
void peersRef.current.get(message.fromPeerId)?.acceptSignal(message.payload);
|
|
return;
|
|
}
|
|
if (message.type === "restart-request") {
|
|
void recoverPeer(
|
|
message.fromPeerId,
|
|
message.connectionId,
|
|
message.rebuild,
|
|
generation,
|
|
).catch((error: unknown) => {
|
|
if (isCurrentGeneration(generation)) {
|
|
setNotice(readableError(error, "connection"));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (message.type === "room-closed") {
|
|
if (!forgetRoom(activeRoom)) {
|
|
return;
|
|
}
|
|
endSharing(
|
|
message.reason === "expired" ? "房间已过期" : "房间已关闭",
|
|
false,
|
|
);
|
|
return;
|
|
}
|
|
if (message.type === "error") {
|
|
if (["INVALID_TOKEN", "ROOM_EXPIRED"].includes(message.code)) {
|
|
if (!forgetRoom(activeRoom)) {
|
|
return;
|
|
}
|
|
endSharing("房间已失效,再次点击将创建新房", false);
|
|
return;
|
|
}
|
|
if (message.code === "AUTH_REQUIRED") {
|
|
return;
|
|
}
|
|
if (message.code === "HOST_ALREADY_CONNECTED") {
|
|
endSharing(hostServerErrorNotice(message.code), false);
|
|
return;
|
|
}
|
|
setNotice(hostServerErrorNotice(message.code));
|
|
}
|
|
}
|
|
|
|
async function startSharing(): Promise<void> {
|
|
if (
|
|
phase === "starting" ||
|
|
phase === "live" ||
|
|
activeGenerationRef.current !== null
|
|
) {
|
|
return;
|
|
}
|
|
const generation = generationRef.current + 1;
|
|
const shareGeneration = createOpaqueId();
|
|
generationRef.current = generation;
|
|
activeGenerationRef.current = generation;
|
|
shareGenerationRef.current = shareGeneration;
|
|
setNotice(null);
|
|
setCopied(false);
|
|
setPhase("starting");
|
|
|
|
let captured: MediaStream;
|
|
try {
|
|
// This must remain the first awaited operation in the button gesture.
|
|
captured = await captureDisplay(qualitySettingsRef.current);
|
|
} catch (error) {
|
|
if (!isCurrentGeneration(generation)) {
|
|
return;
|
|
}
|
|
activeGenerationRef.current = null;
|
|
shareGenerationRef.current = null;
|
|
setNotice(readableError(error, "capture"));
|
|
setPhase("error");
|
|
return;
|
|
}
|
|
|
|
if (!isCurrentGeneration(generation)) {
|
|
captured.getTracks().forEach((track) => track.stop());
|
|
return;
|
|
}
|
|
|
|
streamRef.current = captured;
|
|
setStream(captured);
|
|
setDetails(captureDetails(captured));
|
|
watchCaptureEnd(captured, generation);
|
|
|
|
videoCodecRef.current = await resolveStreamVideoCodec(captured);
|
|
setResolvedVideoCodec(videoCodecRef.current.primary);
|
|
if (!isCurrentGeneration(generation)) {
|
|
captured.getTracks().forEach((track) => track.stop());
|
|
return;
|
|
}
|
|
|
|
let createdRoom: HostRoomState | null = null;
|
|
let claimedRoom = false;
|
|
try {
|
|
let reusableRoom = room;
|
|
createdRoom = reusableRoom ?? hostRoomFromStored(readHostRoom());
|
|
if (!createdRoom) {
|
|
const response = await createRoom(
|
|
creationProfileRef.current.codeEntryPolicy,
|
|
creationProfileRef.current.roomPassword,
|
|
readPreferredRoomId(),
|
|
);
|
|
createdRoom = hostRoomFromCreated(response);
|
|
if (!isCurrentGeneration(generation)) {
|
|
captured.getTracks().forEach((track) => track.stop());
|
|
closeAbandonedRoom(createdRoom);
|
|
return;
|
|
}
|
|
writeHostRoom(createdRoom);
|
|
setRoom(createdRoom);
|
|
claimedRoom = true;
|
|
} else {
|
|
setRoom(createdRoom);
|
|
claimedRoom = true;
|
|
}
|
|
let replacementAttempted = false;
|
|
const connectSignal = (activeRoom: HostRoomState): SignalingClient => {
|
|
let authenticated = false;
|
|
const hostClientId = getStableClientId("host", activeRoom.roomId);
|
|
hostClientIdRef.current = hostClientId;
|
|
const hostFallback = defaultHostDisplayName(hostClientId);
|
|
const initialDisplayName = readDisplayName(hostFallback);
|
|
displayNameRef.current = initialDisplayName;
|
|
setDisplayName(initialDisplayName);
|
|
setDisplayNameDraft(initialDisplayName);
|
|
setDisplayNameError(null);
|
|
const signal = new SignalingClient(
|
|
{
|
|
roomId: activeRoom.roomId,
|
|
role: "host",
|
|
token: activeRoom.hostToken,
|
|
clientId: hostClientId,
|
|
shareGeneration,
|
|
sharingPaused: false,
|
|
qualitySettings: qualitySettingsRef.current,
|
|
routePolicy: routePolicyRef.current,
|
|
viewerPresence: true,
|
|
displayName: initialDisplayName,
|
|
},
|
|
{
|
|
onStatus: (status) => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
signalRef.current === signal
|
|
) {
|
|
setSignalStatus(status);
|
|
}
|
|
},
|
|
onTerminated: (reason) => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
signalRef.current === signal
|
|
) {
|
|
endSharing(hostTerminationMessage(reason), false);
|
|
}
|
|
},
|
|
onAccessRequired: () => {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
signalRef.current === signal
|
|
) {
|
|
endSharing("站点访问已失效,请重新验证", false);
|
|
onAuthorizationRequired?.();
|
|
}
|
|
},
|
|
onMessage: (message) => {
|
|
if (
|
|
!isCurrentGeneration(generation) ||
|
|
signalRef.current !== signal
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
!authenticated &&
|
|
message.type === "error" &&
|
|
(message.code === "INVALID_TOKEN" ||
|
|
message.code === "ROOM_EXPIRED") &&
|
|
!replacementAttempted
|
|
) {
|
|
replacementAttempted = true;
|
|
signalRef.current = null;
|
|
signal.stop();
|
|
setSignalStatus("offline");
|
|
forgetRoom(activeRoom);
|
|
void createReplacementRoom();
|
|
return;
|
|
}
|
|
const reauthenticated =
|
|
authenticated &&
|
|
message.type === "authenticated" &&
|
|
message.role === "host";
|
|
if (message.type === "authenticated" && message.role === "host") {
|
|
authenticated = true;
|
|
iceConfigRef.current = message.iceConfig;
|
|
peersRef.current.forEach((peer) =>
|
|
peer.updateIceConfig(message.iceConfig),
|
|
);
|
|
writeHostRoom({
|
|
...activeRoom,
|
|
expiresAt: message.roomExpiresAt,
|
|
});
|
|
writePreferredRoom(activeRoom.roomId);
|
|
setPhase("live");
|
|
}
|
|
handleSignalMessage(
|
|
message,
|
|
generation,
|
|
activeRoom,
|
|
reauthenticated,
|
|
reauthenticated
|
|
? signal.pendingHostQualitySettings(shareGeneration)
|
|
: null,
|
|
);
|
|
},
|
|
},
|
|
);
|
|
return signal;
|
|
};
|
|
const createReplacementRoom = async (): Promise<void> => {
|
|
try {
|
|
const response = await createRoom(
|
|
creationProfileRef.current.codeEntryPolicy,
|
|
creationProfileRef.current.roomPassword,
|
|
readPreferredRoomId(),
|
|
);
|
|
const replacement = hostRoomFromCreated(response);
|
|
if (!isCurrentGeneration(generation)) {
|
|
captured.getTracks().forEach((track) => track.stop());
|
|
closeAbandonedRoom(replacement);
|
|
return;
|
|
}
|
|
writeHostRoom(replacement);
|
|
setRoom(replacement);
|
|
const replacementSignal = connectSignal(replacement);
|
|
signalRef.current = replacementSignal;
|
|
replacementSignal.start();
|
|
} catch (error) {
|
|
if (!isCurrentGeneration(generation)) {
|
|
return;
|
|
}
|
|
activeGenerationRef.current = null;
|
|
disposeResources(false);
|
|
if (
|
|
error instanceof ApiError &&
|
|
error.status === 401 &&
|
|
onAuthorizationRequired
|
|
) {
|
|
onAuthorizationRequired();
|
|
return;
|
|
}
|
|
setNotice(readableError(error, "room"));
|
|
setPhase("error");
|
|
}
|
|
};
|
|
const signal = connectSignal(createdRoom);
|
|
signalRef.current = signal;
|
|
signal.start();
|
|
} catch (error) {
|
|
if (!isCurrentGeneration(generation)) {
|
|
captured.getTracks().forEach((track) => track.stop());
|
|
if (createdRoom && !claimedRoom) {
|
|
closeAbandonedRoom(createdRoom);
|
|
}
|
|
return;
|
|
}
|
|
activeGenerationRef.current = null;
|
|
disposeResources(false);
|
|
if (
|
|
error instanceof ApiError &&
|
|
error.status === 401 &&
|
|
onAuthorizationRequired
|
|
) {
|
|
onAuthorizationRequired();
|
|
return;
|
|
}
|
|
setNotice(readableError(error, "room"));
|
|
setPhase("error");
|
|
}
|
|
}
|
|
|
|
async function switchSource(): Promise<void> {
|
|
const generation = activeGenerationRef.current;
|
|
if (
|
|
phase !== "live" ||
|
|
generation === null ||
|
|
!isCurrentGeneration(generation) ||
|
|
sourceSwitchRef.current ||
|
|
qualityChangeRef.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(qualitySettingsRef.current);
|
|
} catch (error) {
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
sourceSwitchRef.current === token
|
|
) {
|
|
setNotice(readableError(error, "source"));
|
|
}
|
|
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;
|
|
invalidateSenderQualityEvidence();
|
|
if (routePolicyRef.current.topologyOptimization) {
|
|
signalRef.current?.send({ type: "reset-sender-quality" });
|
|
}
|
|
setMediaPaused(captured, sharingPausedRef.current);
|
|
streamRef.current = captured;
|
|
setStream(captured);
|
|
setDetails(captureDetails(captured));
|
|
watchCaptureEnd(captured, generation);
|
|
|
|
try {
|
|
const activeSfuRoute = hostSfuRouteRef.current;
|
|
const provisional = hostProvisionalChildRef.current;
|
|
const [replacements, , sfuReplaced] = await Promise.all([
|
|
Promise.all(
|
|
[...peersRef.current.entries()].map(async ([peerId, peer]) => {
|
|
try {
|
|
return {
|
|
peerId,
|
|
peer,
|
|
replaced: await peer.replaceStream(captured),
|
|
};
|
|
} catch {
|
|
return { peerId, peer, replaced: false };
|
|
}
|
|
}),
|
|
),
|
|
provisional?.replaceStream(captured) ?? Promise.resolve(true),
|
|
activeSfuRoute?.replaceStream(captured) ?? Promise.resolve(true),
|
|
]);
|
|
|
|
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);
|
|
}
|
|
}
|
|
const sfuWarning =
|
|
activeSfuRoute && hostSfuRouteRef.current === activeSfuRoute
|
|
? activeSfuRoute.getQualityWarning()
|
|
: null;
|
|
if (
|
|
!sfuReplaced &&
|
|
activeSfuRoute &&
|
|
hostSfuRouteRef.current === activeSfuRoute
|
|
) {
|
|
await activeSfuRoute.failActivePublisher();
|
|
}
|
|
|
|
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, "connection"));
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (
|
|
isCurrentGeneration(generation) &&
|
|
sourceSwitchRef.current === token
|
|
) {
|
|
setNotice(
|
|
sourceSwitchNotice({
|
|
failedPeerCount: failedPeerIds.length,
|
|
sfuReplaced,
|
|
sfuWarning,
|
|
}),
|
|
);
|
|
}
|
|
} finally {
|
|
previousStream.getTracks().forEach((track) => track.stop());
|
|
if (retiringStreamRef.current === previousStream) {
|
|
retiringStreamRef.current = null;
|
|
}
|
|
finishSourceSwitch(token);
|
|
}
|
|
}
|
|
|
|
async function copyInvite(): Promise<void> {
|
|
const inviteUrl = room?.inviteUrl;
|
|
if (!inviteUrl) {
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(inviteUrl);
|
|
setCopied(true);
|
|
window.setTimeout(() => {
|
|
setCopied(false);
|
|
}, 1_500);
|
|
} catch {
|
|
setNotice("无法复制邀请链接,请稍后重试");
|
|
}
|
|
}
|
|
|
|
function handleRoomAccessFailure(
|
|
error: unknown,
|
|
activeRoom: HostRoomState,
|
|
): void {
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
onAuthorizationRequired?.();
|
|
}
|
|
if (error instanceof ApiError && error.status === 404) {
|
|
forgetRoom(activeRoom);
|
|
}
|
|
if (!isCurrentRoomAuthority(activeRoom) && roomRef.current !== null) {
|
|
return;
|
|
}
|
|
setNotice(
|
|
error instanceof ApiError
|
|
? error.message
|
|
: "当前无法更新房间设置,请稍后重试",
|
|
);
|
|
}
|
|
|
|
async function changeCodeEntryPolicy(policy: CodeEntryPolicy): Promise<void> {
|
|
if (policy === activeCodeEntryPolicy) {
|
|
setNotice(null);
|
|
return;
|
|
}
|
|
const activeRoom = roomRef.current;
|
|
if (!activeRoom) {
|
|
return;
|
|
}
|
|
const mutation = beginRoomMutation("access");
|
|
if (!mutation) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await updateRoomAccess(
|
|
activeRoom.roomId,
|
|
activeRoom.hostToken,
|
|
{ action: "set-code-entry-policy", policy },
|
|
);
|
|
if (response.type !== "code-entry-policy-updated") {
|
|
throw new Error("Unexpected room access response");
|
|
}
|
|
if (!isCurrentRoomAuthority(activeRoom)) {
|
|
return;
|
|
}
|
|
const profile = {
|
|
codeEntryPolicy: response.codeEntryPolicy,
|
|
roomPassword: creationProfileRef.current.roomPassword,
|
|
};
|
|
saveCreationProfile(profile);
|
|
creationProfileRef.current = profile;
|
|
setCreationProfile(profile);
|
|
setViewerPasswordEnabled(response.viewerPasswordEnabled);
|
|
setViewerPasswordVisible(false);
|
|
const updatedRoom = {
|
|
...roomRef.current!,
|
|
codeEntryPolicy: response.codeEntryPolicy,
|
|
};
|
|
roomRef.current = updatedRoom;
|
|
setRoom(updatedRoom);
|
|
setNotice(
|
|
response.codeEntryPolicy === "open"
|
|
? "房间已设为公开"
|
|
: response.viewerPasswordEnabled
|
|
? "房间已设为私密,可凭邀请或密码加入"
|
|
: "房间已设为私密,仅限邀请加入",
|
|
);
|
|
} catch (error) {
|
|
handleRoomAccessFailure(error, activeRoom);
|
|
} finally {
|
|
finishRoomMutation(mutation);
|
|
}
|
|
}
|
|
|
|
async function changeViewerGrant(action: "rotate" | "revoke"): Promise<void> {
|
|
const activeRoom = roomRef.current;
|
|
if (!activeRoom) {
|
|
return;
|
|
}
|
|
const mutation = beginRoomMutation("access");
|
|
if (!mutation) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await updateRoomAccess(
|
|
activeRoom.roomId,
|
|
activeRoom.hostToken,
|
|
{
|
|
action:
|
|
action === "rotate"
|
|
? "rotate-viewer-grant"
|
|
: "revoke-viewer-grant",
|
|
},
|
|
);
|
|
if (response.type !== "viewer-grant-updated") {
|
|
throw new Error("Unexpected room access response");
|
|
}
|
|
if (!isCurrentRoomAuthority(activeRoom)) {
|
|
return;
|
|
}
|
|
replaceViewerInvite(activeRoom.roomId, response.inviteUrl);
|
|
const updatedRoom = {
|
|
...roomRef.current!,
|
|
inviteUrl: response.inviteUrl,
|
|
};
|
|
roomRef.current = updatedRoom;
|
|
setRoom(updatedRoom);
|
|
setNotice(
|
|
response.inviteUrl
|
|
? "邀请链接已更新"
|
|
: "邀请链接已撤销",
|
|
);
|
|
} catch (error) {
|
|
handleRoomAccessFailure(error, activeRoom);
|
|
} finally {
|
|
finishRoomMutation(mutation);
|
|
}
|
|
}
|
|
|
|
async function changeViewerPassword(password: string | null): Promise<void> {
|
|
if (
|
|
password !== null &&
|
|
!viewerPasswordSchema.safeParse(password).success
|
|
) {
|
|
setNotice(
|
|
`房间密码只需 1-${MAX_VIEWER_PASSWORD_LENGTH} 个可见字符`,
|
|
);
|
|
return;
|
|
}
|
|
const activeRoom = roomRef.current;
|
|
if (!activeRoom) {
|
|
return;
|
|
}
|
|
const hadPassword = viewerPasswordEnabled;
|
|
const mutation = beginRoomMutation("access");
|
|
if (!mutation) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await updateRoomAccess(
|
|
activeRoom.roomId,
|
|
activeRoom.hostToken,
|
|
{ action: "set-viewer-password", password },
|
|
);
|
|
if (response.type !== "viewer-password-updated") {
|
|
throw new Error("Unexpected room access response");
|
|
}
|
|
if (!isCurrentRoomAuthority(activeRoom)) {
|
|
return;
|
|
}
|
|
const passwordProfile = {
|
|
codeEntryPolicy:
|
|
activeRoom.codeEntryPolicy ??
|
|
creationProfileRef.current.codeEntryPolicy,
|
|
roomPassword: password,
|
|
};
|
|
saveCreationProfile(passwordProfile);
|
|
creationProfileRef.current = passwordProfile;
|
|
setCreationProfile(passwordProfile);
|
|
setViewerPasswordEnabled(response.enabled);
|
|
setViewerPasswordDraft(password ?? "");
|
|
setViewerPasswordVisible(false);
|
|
setNotice(
|
|
password === null
|
|
? "房间密码已移除,仅限邀请加入"
|
|
: hadPassword
|
|
? "房间密码已更新"
|
|
: "房间密码已设置",
|
|
);
|
|
} catch (error) {
|
|
handleRoomAccessFailure(error, activeRoom);
|
|
} finally {
|
|
finishRoomMutation(mutation);
|
|
}
|
|
}
|
|
|
|
function commitDisplayName(): void {
|
|
const hostFallback = hostClientIdRef.current
|
|
? defaultHostDisplayName(hostClientIdRef.current)
|
|
: DEFAULT_HOST_DISPLAY_NAME_PREFIX;
|
|
const saved = saveDisplayName(displayNameDraft, hostFallback);
|
|
if (!saved) {
|
|
setDisplayNameError("名称格式无效或超过 24 个字符");
|
|
return;
|
|
}
|
|
displayNameRef.current = saved;
|
|
setDisplayName(saved);
|
|
setDisplayNameDraft(saved);
|
|
setDisplayNameError(null);
|
|
setEditingDisplayName(false);
|
|
if (!signalRef.current?.setDisplayName(saved)) {
|
|
setNotice("开始分享并连接后才能修改昵称");
|
|
}
|
|
}
|
|
|
|
const activeCodeEntryPolicy =
|
|
room?.codeEntryPolicy ?? creationProfile.codeEntryPolicy;
|
|
return (
|
|
<div className="app-shell">
|
|
<AppHeader
|
|
status={
|
|
showConnectionDetails ? (
|
|
<SignalStatusBadge state={signalStatus} />
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
<main className="host-workspace">
|
|
<section className="broadcast-area" aria-labelledby="broadcast-heading">
|
|
<div className="section-heading">
|
|
<div>
|
|
<div className="title-line">
|
|
<h1 id="broadcast-heading">
|
|
{hostPresence?.displayName ?? displayName} 的屏幕
|
|
</h1>
|
|
{room && (
|
|
<RoomCode
|
|
roomId={room.roomId}
|
|
onReplace={replaceCurrentRoom}
|
|
replaceDisabled={phase === "starting" || roomMutating}
|
|
/>
|
|
)}
|
|
</div>
|
|
<p className="section-meta">
|
|
{phase === "live"
|
|
? `${viewers.length}/${maxViewers ?? "-"} 人在线`
|
|
: phase === "starting"
|
|
? "正在连接"
|
|
: phase === "ended" && room
|
|
? "已停止分享"
|
|
: room
|
|
? "房间已就绪"
|
|
: "尚未开始"}
|
|
</p>
|
|
</div>
|
|
{(phase === "live" || phase === "starting") && (
|
|
<div className="broadcast-actions">
|
|
{phase === "live" && (
|
|
<button
|
|
className="button button-secondary"
|
|
type="button"
|
|
disabled={
|
|
switchingSource ||
|
|
changingQuality
|
|
}
|
|
onClick={toggleSharingPause}
|
|
>
|
|
{sharingPaused ? (
|
|
<Play size={16} fill="currentColor" aria-hidden="true" />
|
|
) : (
|
|
<Pause size={16} fill="currentColor" aria-hidden="true" />
|
|
)}
|
|
{sharingPaused ? "恢复分享" : "暂停分享"}
|
|
</button>
|
|
)}
|
|
{phase === "live" && (
|
|
<button
|
|
className="button button-secondary"
|
|
type="button"
|
|
disabled={
|
|
switchingSource ||
|
|
changingQuality
|
|
}
|
|
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>
|
|
|
|
<form
|
|
className={`viewer-name-control host-name-control${
|
|
editingDisplayName ? " is-editing" : ""
|
|
}`}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
commitDisplayName();
|
|
}}
|
|
>
|
|
<label
|
|
htmlFor={editingDisplayName ? "host-display-name" : undefined}
|
|
>
|
|
昵称
|
|
</label>
|
|
{editingDisplayName ? (
|
|
<>
|
|
<input
|
|
id="host-display-name"
|
|
type="text"
|
|
value={displayNameDraft}
|
|
maxLength={96}
|
|
autoComplete="nickname"
|
|
autoFocus
|
|
aria-invalid={displayNameError ? "true" : undefined}
|
|
onChange={(event) => {
|
|
setDisplayNameDraft(event.target.value);
|
|
setDisplayNameError(null);
|
|
}}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="icon-button"
|
|
title="保存昵称"
|
|
aria-label="保存昵称"
|
|
disabled={displayNameDraft === displayName}
|
|
>
|
|
<Save size={17} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
title="取消编辑"
|
|
aria-label="取消编辑昵称"
|
|
onClick={() => {
|
|
setDisplayNameDraft(displayName);
|
|
setDisplayNameError(null);
|
|
setEditingDisplayName(false);
|
|
}}
|
|
>
|
|
<X size={17} />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="viewer-name-value">{displayName}</span>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
title="编辑昵称"
|
|
aria-label="编辑昵称"
|
|
onClick={() => {
|
|
setDisplayNameDraft(displayName);
|
|
setDisplayNameError(null);
|
|
setEditingDisplayName(true);
|
|
}}
|
|
>
|
|
<Pencil size={17} />
|
|
</button>
|
|
</>
|
|
)}
|
|
{displayNameError && (
|
|
<span className="viewer-name-error" role="alert">
|
|
{displayNameError}
|
|
</span>
|
|
)}
|
|
</form>
|
|
|
|
<div
|
|
className="video-stage local-stage"
|
|
role="group"
|
|
aria-label="分享或加入房间"
|
|
>
|
|
{stream ? (
|
|
<video ref={videoRef} autoPlay muted playsInline />
|
|
) : phase === "idle" ||
|
|
phase === "ended" ||
|
|
phase === "error" ? (
|
|
<div className="stage-placeholder stage-entry">
|
|
{phase === "ended" && room && (
|
|
<span className="stage-status">已停止分享</span>
|
|
)}
|
|
<StageEntryActions
|
|
joiningRoom={joiningRoom}
|
|
onStartSharing={() => {
|
|
setJoiningRoom(false);
|
|
void startSharing();
|
|
}}
|
|
onJoinToggle={() =>
|
|
setJoiningRoom((current) => !current)
|
|
}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="stage-placeholder">
|
|
<MonitorUp size={36} strokeWidth={1.5} aria-hidden="true" />
|
|
</div>
|
|
)}
|
|
{(phase === "starting" ||
|
|
switchingSource ||
|
|
sharingPaused ||
|
|
(stream !== null && localPreviewPaused)) && (
|
|
<div className="stage-overlay" role="status">
|
|
{switchingSource ? (
|
|
<RefreshCw
|
|
size={36}
|
|
strokeWidth={1.5}
|
|
className="spin"
|
|
aria-hidden="true"
|
|
/>
|
|
) : sharingPaused ? (
|
|
<Pause size={36} strokeWidth={1.5} aria-hidden="true" />
|
|
) : phase === "starting" ? (
|
|
<LoaderCircle
|
|
size={36}
|
|
strokeWidth={1.5}
|
|
className="spin"
|
|
aria-hidden="true"
|
|
/>
|
|
) : (
|
|
<EyeOff size={36} strokeWidth={1.5} aria-hidden="true" />
|
|
)}
|
|
<span>
|
|
{switchingSource
|
|
? "正在切换来源"
|
|
: sharingPaused
|
|
? "音视频分享已暂停"
|
|
: phase === "starting"
|
|
? "正在连接"
|
|
: "本地预览已暂停,分享仍在继续"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showConnectionDetails && details && stream && (
|
|
<div className="capture-strip" aria-label="实际捕获参数">
|
|
<span>{details.resolution}</span>
|
|
<span>{details.frameRate ? `${details.frameRate.toFixed(0)} fps` : "帧率未知"}</span>
|
|
<span>{resolvedVideoCodec?.toUpperCase() ?? "编码待定"}</span>
|
|
<span>{details.hasAudio ? "含音频" : "无音频"}</span>
|
|
</div>
|
|
)}
|
|
|
|
{!details?.hasAudio && stream && (
|
|
<WarningBanner>当前来源没有可共享音频</WarningBanner>
|
|
)}
|
|
{qualityLimitation && (
|
|
<WarningBanner>{qualityLimitation}</WarningBanner>
|
|
)}
|
|
{notice && (
|
|
<div className="notice" role="status" aria-live="polite">
|
|
{notice}
|
|
</div>
|
|
)}
|
|
|
|
<ConnectionDetailsToggle
|
|
checked={showConnectionDetails}
|
|
onChange={setShowConnectionDetails}
|
|
/>
|
|
|
|
<div className="setup-controls">
|
|
<div className="quality-controls">
|
|
<fieldset className="control-group">
|
|
<legend>视频预设</legend>
|
|
<div className="segmented-control">
|
|
{(Object.keys(QUALITY_PROFILES) as QualityProfileId[]).map(
|
|
(id) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
className={
|
|
selectedQualityProfileId === id
|
|
? "is-selected"
|
|
: undefined
|
|
}
|
|
aria-pressed={selectedQualityProfileId === id}
|
|
title={`${QUALITY_PROFILE_LABELS[id]} · ${(QUALITY_PROFILES[id].maxBitrate / 1_000_000).toFixed(0)} Mbps`}
|
|
disabled={
|
|
phase === "starting" ||
|
|
switchingSource
|
|
}
|
|
onClick={() =>
|
|
void changeQuality({
|
|
...QUALITY_PROFILES[id],
|
|
screenAudioQuality: resolveScreenAudioQuality(
|
|
advancedQualityRef.current.screenAudioQuality,
|
|
),
|
|
})
|
|
}
|
|
>
|
|
{QUALITY_PROFILE_LABELS[id]}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<details className="advanced-quality">
|
|
<summary>高级设置</summary>
|
|
<div className="advanced-quality-grid">
|
|
<label>
|
|
<span>分辨率上限</span>
|
|
<select
|
|
value={advancedQuality.resolution}
|
|
disabled={phase === "starting" || switchingSource}
|
|
onChange={(event) =>
|
|
changeAdvancedQuality({
|
|
resolution: event.target
|
|
.value as QualitySettings["resolution"],
|
|
})
|
|
}
|
|
>
|
|
{Object.entries(QUALITY_RESOLUTIONS).map(
|
|
([resolution, option]) => (
|
|
<option key={resolution} value={resolution}>
|
|
{option.label}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>帧率上限</span>
|
|
<div className="range-control">
|
|
<input
|
|
type="range"
|
|
min="15"
|
|
max="60"
|
|
step="5"
|
|
value={advancedQuality.maxFramerate}
|
|
disabled={phase === "starting" || switchingSource}
|
|
onChange={(event) =>
|
|
changeAdvancedQuality({
|
|
maxFramerate: Number(event.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<output>{advancedQuality.maxFramerate} fps</output>
|
|
</div>
|
|
</label>
|
|
<label>
|
|
<span>视频码率上限</span>
|
|
<div className="range-control">
|
|
<input
|
|
type="range"
|
|
min="2000000"
|
|
max="12000000"
|
|
step="500000"
|
|
value={advancedQuality.maxBitrate}
|
|
disabled={phase === "starting" || switchingSource}
|
|
onChange={(event) =>
|
|
changeAdvancedQuality({
|
|
maxBitrate: Number(event.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<output>
|
|
{(advancedQuality.maxBitrate / 1_000_000).toFixed(1)} Mbps
|
|
</output>
|
|
</div>
|
|
</label>
|
|
<fieldset className="control-group quality-priority">
|
|
<legend>画面偏好</legend>
|
|
<div className="segmented-control">
|
|
{(
|
|
Object.keys(
|
|
DEGRADATION_PREFERENCE_LABELS,
|
|
) as DegradationPreference[]
|
|
).map((preference) => (
|
|
<button
|
|
key={preference}
|
|
type="button"
|
|
className={
|
|
advancedQuality.degradationPreference === preference
|
|
? "is-selected"
|
|
: undefined
|
|
}
|
|
aria-pressed={
|
|
advancedQuality.degradationPreference === preference
|
|
}
|
|
disabled={phase !== "live" || switchingSource}
|
|
onClick={() =>
|
|
changeAdvancedQuality({
|
|
degradationPreference: preference,
|
|
})
|
|
}
|
|
>
|
|
<span>
|
|
{DEGRADATION_PREFERENCE_LABELS[preference]}
|
|
</span>
|
|
<small>
|
|
{DEGRADATION_PREFERENCE_HINTS[preference]}
|
|
</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
<fieldset className="control-group quality-priority">
|
|
<legend>音频质量</legend>
|
|
<div className="segmented-control">
|
|
{(
|
|
Object.keys(
|
|
SCREEN_AUDIO_QUALITY_LABELS,
|
|
) as ScreenAudioQuality[]
|
|
).map((audioQuality) => (
|
|
<button
|
|
key={audioQuality}
|
|
type="button"
|
|
className={
|
|
resolveScreenAudioQuality(
|
|
advancedQuality.screenAudioQuality,
|
|
) === audioQuality
|
|
? "is-selected"
|
|
: undefined
|
|
}
|
|
aria-pressed={
|
|
resolveScreenAudioQuality(
|
|
advancedQuality.screenAudioQuality,
|
|
) === audioQuality
|
|
}
|
|
title={`${SCREEN_AUDIO_QUALITY_LABELS[audioQuality]} · ${SCREEN_AUDIO_BITRATES[audioQuality] / 1_000} kbps 上限`}
|
|
disabled={
|
|
phase === "starting" ||
|
|
switchingSource
|
|
}
|
|
onClick={() =>
|
|
changeScreenAudioQuality(audioQuality)
|
|
}
|
|
>
|
|
<span>
|
|
{SCREEN_AUDIO_QUALITY_LABELS[audioQuality]}
|
|
</span>
|
|
<small>
|
|
{SCREEN_AUDIO_BITRATES[audioQuality] / 1_000} kbps
|
|
</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
<fieldset className="control-group route-policy-controls">
|
|
<legend>路由策略</legend>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={routePolicy.topologyOptimization}
|
|
disabled={phase === "starting" || phase === "live"}
|
|
onChange={(event) =>
|
|
changeRoutePolicy({
|
|
topologyOptimization: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
<span>
|
|
自动优化拓扑
|
|
<small>观看中逐步选择更健康的连接</small>
|
|
</span>
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={routePolicy.peerOnly}
|
|
disabled={phase === "starting" || phase === "live"}
|
|
onChange={(event) =>
|
|
changeRoutePolicy({ peerOnly: event.target.checked })
|
|
}
|
|
/>
|
|
<span>
|
|
纯 P2P
|
|
<small>不使用媒体服务器,无法直连时停止尝试</small>
|
|
</span>
|
|
</label>
|
|
</fieldset>
|
|
<fieldset className="control-group quality-priority">
|
|
<legend>视频编码</legend>
|
|
<div className="segmented-control">
|
|
{(["vp8", "auto", "h264"] as const).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
className={
|
|
videoCodecMode === mode ? "is-selected" : undefined
|
|
}
|
|
aria-pressed={videoCodecMode === mode}
|
|
disabled={phase === "starting" || phase === "live"}
|
|
onClick={() => changeVideoCodecMode(mode)}
|
|
>
|
|
<span>{mode === "auto" ? "自动" : mode.toUpperCase()}</span>
|
|
<small>
|
|
{mode === "vp8"
|
|
? "兼容优先"
|
|
: mode === "h264"
|
|
? "硬件优先"
|
|
: resolvedVideoCodec
|
|
? resolvedVideoCodec.toUpperCase()
|
|
: "自动选择"}
|
|
</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
{room && (
|
|
<div className="invite-bar">
|
|
<div className="invite-primary">
|
|
<div className="invite-heading">
|
|
<div className="invite-heading-copy">
|
|
<span className="field-label">邀请链接</span>
|
|
<span className="invite-status">
|
|
{room.inviteUrl ? "可用" : "暂无"}
|
|
</span>
|
|
</div>
|
|
<div className="invite-icon-actions">
|
|
<button
|
|
className="icon-button invite-icon-action"
|
|
type="button"
|
|
title="更新邀请链接"
|
|
aria-label="更新邀请链接"
|
|
disabled={roomMutating}
|
|
onClick={() => void changeViewerGrant("rotate")}
|
|
>
|
|
<RefreshCw size={17} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
className="icon-button invite-icon-action is-danger"
|
|
type="button"
|
|
title="撤销邀请链接"
|
|
aria-label="撤销邀请链接"
|
|
disabled={!room.inviteUrl || roomMutating}
|
|
onClick={() => void changeViewerGrant("revoke")}
|
|
>
|
|
<Link2Off size={17} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="invite-copy-row">
|
|
<span
|
|
className="invite-url"
|
|
title={room.inviteUrl ?? undefined}
|
|
>
|
|
{room.inviteUrl ??
|
|
(activeCodeEntryPolicy === "open"
|
|
? "暂无邀请链接,仍可凭房间号加入"
|
|
: viewerPasswordEnabled
|
|
? "暂无邀请链接,仍可凭房间号和密码加入"
|
|
: "暂无邀请链接,请先更新链接再邀请他人")}
|
|
</span>
|
|
<button
|
|
className="button button-primary invite-copy-action"
|
|
type="button"
|
|
disabled={!room.inviteUrl || roomMutating}
|
|
onClick={() => void copyInvite()}
|
|
>
|
|
{copied ? (
|
|
<Check size={16} aria-hidden="true" />
|
|
) : (
|
|
<Copy size={16} aria-hidden="true" />
|
|
)}
|
|
{copied ? "已复制" : "复制邀请链接"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="room-entry-policy">
|
|
<div className="room-entry-heading">
|
|
<span className="field-label">准入方式</span>
|
|
</div>
|
|
<div className="segmented-control room-policy-control">
|
|
{(
|
|
[
|
|
["open", "公开", Globe2],
|
|
["private", "私密", LockKeyhole],
|
|
] as const
|
|
).map(([policy, label, Icon]) => (
|
|
<button
|
|
key={policy}
|
|
type="button"
|
|
className={`room-policy-option policy-${policy}${
|
|
activeCodeEntryPolicy === policy
|
|
? " is-selected"
|
|
: ""
|
|
}`}
|
|
aria-pressed={activeCodeEntryPolicy === policy}
|
|
disabled={roomMutating}
|
|
onClick={() => void changeCodeEntryPolicy(policy)}
|
|
>
|
|
<Icon size={16} aria-hidden="true" />
|
|
<span>{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{activeCodeEntryPolicy === "private" && (
|
|
<form
|
|
className={`viewer-password-control${
|
|
viewerPasswordEnabled ? " has-password" : ""
|
|
}`}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void changeViewerPassword(viewerPasswordDraft);
|
|
}}
|
|
>
|
|
<label htmlFor="viewer-password">
|
|
{viewerPasswordEnabled
|
|
? "房间密码已设置"
|
|
: "房间密码未设置"}
|
|
</label>
|
|
<span className="input-with-icon">
|
|
<KeyRound size={16} aria-hidden="true" />
|
|
<input
|
|
id="viewer-password"
|
|
type={viewerPasswordVisible ? "text" : "password"}
|
|
value={viewerPasswordDraft}
|
|
maxLength={MAX_VIEWER_PASSWORD_LENGTH}
|
|
autoComplete="new-password"
|
|
placeholder={
|
|
viewerPasswordEnabled
|
|
? "输入密码"
|
|
: "设置后可凭房间号加入"
|
|
}
|
|
autoFocus={!viewerPasswordEnabled}
|
|
disabled={roomMutating}
|
|
onChange={(event) =>
|
|
setViewerPasswordDraft(event.target.value)
|
|
}
|
|
/>
|
|
{viewerPasswordEnabled && viewerPasswordDraft.length > 0 && (
|
|
<button
|
|
className="password-visibility-action"
|
|
type="button"
|
|
title={viewerPasswordVisible ? "隐藏密码" : "显示密码"}
|
|
aria-label={
|
|
viewerPasswordVisible ? "隐藏房间密码" : "显示房间密码"
|
|
}
|
|
aria-pressed={viewerPasswordVisible}
|
|
disabled={roomMutating}
|
|
onClick={() =>
|
|
setViewerPasswordVisible((current) => !current)
|
|
}
|
|
>
|
|
{viewerPasswordVisible ? (
|
|
<EyeOff size={17} aria-hidden="true" />
|
|
) : (
|
|
<Eye size={17} aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</span>
|
|
<button
|
|
className="icon-button"
|
|
type="submit"
|
|
title={viewerPasswordEnabled ? "更改房间密码" : "设置房间密码"}
|
|
aria-label={
|
|
viewerPasswordEnabled ? "更改房间密码" : "设置房间密码"
|
|
}
|
|
disabled={
|
|
roomMutating ||
|
|
viewerPasswordDraft.length === 0 ||
|
|
(viewerPasswordEnabled &&
|
|
viewerPasswordDraft ===
|
|
creationProfile.roomPassword)
|
|
}
|
|
>
|
|
<Check size={18} aria-hidden="true" />
|
|
</button>
|
|
{viewerPasswordEnabled && (
|
|
<button
|
|
className="icon-button"
|
|
type="button"
|
|
title="移除房间密码"
|
|
aria-label="移除房间密码"
|
|
disabled={roomMutating}
|
|
onClick={() => void changeViewerPassword(null)}
|
|
>
|
|
<X size={18} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<aside className="viewer-panel" aria-labelledby="viewer-heading">
|
|
<div className="viewer-panel-heading">
|
|
<div>
|
|
<h2 id="viewer-heading">观看者</h2>
|
|
<span>在线 {viewers.length}/{maxViewers ?? "-"}</span>
|
|
</div>
|
|
<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 && (
|
|
<TopologyView
|
|
hostPeerId={hostPresence?.peerId ?? hostPeerIdRef.current}
|
|
hostLabel={labeledHostPresence?.label ?? displayName}
|
|
viewers={viewers}
|
|
/>
|
|
)}
|
|
|
|
<div className="viewer-list">
|
|
{viewers.map((viewer) => {
|
|
const snapshot =
|
|
viewer.upstream.kind === "peer" &&
|
|
viewer.upstream.peerId ===
|
|
(hostPresence?.peerId ?? hostPeerIdRef.current)
|
|
? peerSnapshots.get(viewer.peerId)
|
|
: undefined;
|
|
const qualityPresentation = viewerQualityEvidence.get(
|
|
viewer.peerId,
|
|
);
|
|
const qualityEvidence = qualityPresentation?.evidence;
|
|
const hasMatchingQualityEvidence =
|
|
qualityEvidence !== undefined &&
|
|
qualityEvidenceUpstreamMatches(
|
|
qualityEvidence,
|
|
viewer.upstream,
|
|
);
|
|
const hasCurrentQualityEvidence =
|
|
hasMatchingQualityEvidence &&
|
|
qualityPresentation?.fresh === true;
|
|
const hasCommittedMedia = viewer.mediaReady === true;
|
|
const hasCurrentRouteEvidence =
|
|
hasPeerRouteEvidence(snapshot) ||
|
|
hasCurrentQualityEvidence ||
|
|
hasCommittedMedia;
|
|
const hasCurrentConnectionEvidence =
|
|
snapshot?.connectionState === "connected" ||
|
|
hasCurrentQualityEvidence ||
|
|
hasCommittedMedia;
|
|
const viewerState =
|
|
hasCurrentConnectionEvidence
|
|
? "connected"
|
|
: (snapshot?.connectionState ?? "routing");
|
|
const detailMetrics = hasMatchingQualityEvidence
|
|
? metricsFromQualityEvidence(qualityEvidence)
|
|
: snapshot && hasPeerRouteEvidence(snapshot)
|
|
? snapshot.metrics
|
|
: null;
|
|
const detailDirection = hasMatchingQualityEvidence
|
|
? "receive"
|
|
: "send";
|
|
return (
|
|
<article className="viewer-item" key={viewer.peerId}>
|
|
<div className="viewer-item-heading">
|
|
<div>
|
|
<h3 title={viewer.label}>{viewer.label}</h3>
|
|
<PeerStatusBadge state={viewerState} />
|
|
</div>
|
|
</div>
|
|
{showConnectionDetails &&
|
|
hasCurrentRouteEvidence &&
|
|
viewer.upstream.kind !== "none" && (
|
|
<div className="viewer-transport-heading">
|
|
<MediaRouteBadge
|
|
route={viewer.upstream.kind === "peer" ? "p2p" : "sfu"}
|
|
/>
|
|
</div>
|
|
)}
|
|
{showConnectionDetails && detailMetrics && (
|
|
<StatsGrid
|
|
metrics={detailMetrics}
|
|
direction={detailDirection}
|
|
progressive
|
|
/>
|
|
)}
|
|
{snapshot?.error && (
|
|
<p className="inline-error">{snapshot.error}</p>
|
|
)}
|
|
</article>
|
|
);
|
|
})}
|
|
{viewers.length === 0 && (
|
|
<div className="empty-viewers">
|
|
<Users size={24} strokeWidth={1.5} aria-hidden="true" />
|
|
<span>{phase === "live" ? "等待朋友加入" : "暂无观看者"}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|