feat: stream private office screens
This commit is contained in:
+239
-8
@@ -89,7 +89,9 @@ import {
|
||||
createOfficeScreenPanel,
|
||||
type MediaSurfaceDescriptor,
|
||||
type OfficeScreenPanel,
|
||||
type OfficeScreenRemoteStatus,
|
||||
} from "./media/index.ts";
|
||||
import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts";
|
||||
/**
|
||||
* Three type-only imports and not one value among them, which is what keeps the
|
||||
* office and the instruments out of the entry chunk.
|
||||
@@ -454,7 +456,18 @@ let sharedScreen: {
|
||||
stream: MediaStream;
|
||||
video: HTMLVideoElement;
|
||||
texture: VideoTexture;
|
||||
remote: RemoteOfficeMedia | null;
|
||||
} | null = null;
|
||||
let remoteViewedScreen: {
|
||||
screenId: string;
|
||||
officeId: string;
|
||||
video: HTMLVideoElement;
|
||||
texture: VideoTexture;
|
||||
remote: RemoteOfficeMedia;
|
||||
bound: boolean;
|
||||
} | null = null;
|
||||
let remoteMediaOperation = 0;
|
||||
let remoteViewerRequestScreenId: string | null = null;
|
||||
/**
|
||||
* The plan panel's other occupant.
|
||||
*
|
||||
@@ -2194,6 +2207,7 @@ async function initializeRealtimePresence(): Promise<void> {
|
||||
|
||||
window.addEventListener("pagehide", () => {
|
||||
realtimePageActive = false;
|
||||
disposeOfficeScreenUi();
|
||||
realtimeOperation += 1;
|
||||
stopRealtimeSubscription?.();
|
||||
stopRealtimeSubscription = null;
|
||||
@@ -2266,11 +2280,206 @@ function openProfileEditor(): void {
|
||||
editor.open();
|
||||
}
|
||||
|
||||
function screenBinding(surface: MediaSurfaceDescriptor) {
|
||||
return {
|
||||
officeId: surface.officeId,
|
||||
levelId: surface.levelId,
|
||||
roomId: surface.roomId,
|
||||
screenId: surface.screenId,
|
||||
};
|
||||
}
|
||||
|
||||
function remotePanelStatus(screenId: string, status: OfficeScreenRemoteStatus): void {
|
||||
officeScreenPanel?.setRemoteStatus(screenId, status);
|
||||
renderOfficeBadge();
|
||||
}
|
||||
|
||||
function isRemoteAuthorizationError(error: unknown): boolean {
|
||||
return error instanceof Error && /\((?:401|403)\)/.test(error.message);
|
||||
}
|
||||
|
||||
function releaseRemoteViewer(
|
||||
status: OfficeScreenRemoteStatus = "off",
|
||||
action: "revoke" | "dispose" = "revoke",
|
||||
): void {
|
||||
const viewing = remoteViewedScreen;
|
||||
const pendingScreenId = remoteViewerRequestScreenId;
|
||||
remoteMediaOperation += 1;
|
||||
remoteViewerRequestScreenId = null;
|
||||
if (pendingScreenId && pendingScreenId !== viewing?.screenId) {
|
||||
remotePanelStatus(pendingScreenId, status);
|
||||
}
|
||||
if (!viewing) return;
|
||||
remoteViewedScreen = null;
|
||||
office?.clearMediaSurface(viewing.screenId);
|
||||
// `OfficeScreenPanel` owns defensive descriptor snapshots rather than a
|
||||
// live view of OfficeScene. Refresh after clear just as the bind path does,
|
||||
// otherwise the panel can keep saying "Media active" after remote teardown.
|
||||
officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
|
||||
viewing.texture.dispose();
|
||||
viewing.video.pause();
|
||||
viewing.video.srcObject = null;
|
||||
remotePanelStatus(viewing.screenId, status);
|
||||
const finish = action === "revoke" ? viewing.remote.revoke() : viewing.remote.dispose();
|
||||
void finish.catch(() => undefined);
|
||||
}
|
||||
|
||||
function syncRemoteViewerState(
|
||||
viewing: NonNullable<typeof remoteViewedScreen>,
|
||||
state: RemoteMediaState,
|
||||
): void {
|
||||
if (remoteViewedScreen !== viewing) return;
|
||||
if (state.status === "reconnecting") remotePanelStatus(viewing.screenId, "reconnecting");
|
||||
else if (state.status === "connecting") remotePanelStatus(viewing.screenId, "connecting");
|
||||
else if (state.status === "live") remotePanelStatus(viewing.screenId, "live");
|
||||
if (state.hasRemoteVideo && !viewing.bound) {
|
||||
// Playback is a consequence of the user's screen-specific opt-in click;
|
||||
// the transport itself deliberately never calls `play()`.
|
||||
void viewing.video.play().then(() => {
|
||||
if (remoteViewedScreen !== viewing || !office || !inside) return;
|
||||
viewing.bound = office.bindMediaSurface(
|
||||
viewing.screenId,
|
||||
{ canView: true, optedIn: true },
|
||||
viewing.texture,
|
||||
);
|
||||
officeScreenPanel?.update(office.listMediaSurfaces());
|
||||
remotePanelStatus(viewing.screenId, viewing.bound ? "live" : "error");
|
||||
}).catch(() => {
|
||||
if (remoteViewedScreen === viewing) {
|
||||
showDetail("The remote screen arrived, but this browser could not start video playback.");
|
||||
releaseRemoteViewer("error", "dispose");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (state.status === "stopped" || state.status === "revoked") {
|
||||
showDetail(state.status === "revoked" ? "Remote screen access was revoked." : "The remote screen share stopped.");
|
||||
releaseRemoteViewer("stopped", "dispose");
|
||||
}
|
||||
}
|
||||
|
||||
async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void> {
|
||||
if (access.subject === null || !inside || !office || office.depth !== "full") return;
|
||||
releaseRemoteViewer("off");
|
||||
const operation = ++remoteMediaOperation;
|
||||
remoteViewerRequestScreenId = surface.screenId;
|
||||
remotePanelStatus(surface.screenId, "connecting");
|
||||
showDetail(`Connecting to ${surface.screenId}…`);
|
||||
try {
|
||||
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([
|
||||
import("./media/remoteMedia.ts"),
|
||||
import("./media/iceClient.ts"),
|
||||
]);
|
||||
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
|
||||
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
|
||||
office.depth !== "full" || officeId !== surface.officeId || !optedIn) return;
|
||||
const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch });
|
||||
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
|
||||
office.depth !== "full" || officeId !== surface.officeId ||
|
||||
!(officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false)) return;
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.autoplay = false;
|
||||
const texture = new VideoTexture(video);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
let viewing: NonNullable<typeof remoteViewedScreen>;
|
||||
const remote = createRemoteOfficeMedia({
|
||||
role: "viewer",
|
||||
binding: screenBinding(surface),
|
||||
authenticatedFetch: authFetch,
|
||||
peerConnectionConfiguration: ice.configuration,
|
||||
onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); },
|
||||
onError: (error) => {
|
||||
if (remoteViewedScreen !== viewing) return;
|
||||
if (isRemoteAuthorizationError(error)) {
|
||||
showDetail("Remote screen authorization ended.");
|
||||
releaseRemoteViewer("unavailable", "dispose");
|
||||
} else {
|
||||
remotePanelStatus(surface.screenId, "reconnecting");
|
||||
}
|
||||
},
|
||||
});
|
||||
viewing = { screenId: surface.screenId, officeId: surface.officeId, video, texture, remote, bound: false };
|
||||
remoteViewerRequestScreenId = null;
|
||||
remoteViewedScreen = viewing;
|
||||
await remote.startViewer({ viewerOptIn: true, video });
|
||||
if (remoteViewedScreen === viewing) showDetail(`Waiting for ${surface.screenId} remote video…`);
|
||||
} catch (error) {
|
||||
if (operation !== remoteMediaOperation) return;
|
||||
remoteViewerRequestScreenId = null;
|
||||
releaseRemoteViewer("unavailable", "dispose");
|
||||
remotePanelStatus(surface.screenId, "unavailable");
|
||||
showDetail(isRemoteAuthorizationError(error)
|
||||
? "Remote screen authorization ended. Sign in again to reconnect."
|
||||
: "No authorized remote share is available for that screen.");
|
||||
}
|
||||
}
|
||||
|
||||
async function startRemotePresenter(
|
||||
surface: MediaSurfaceDescriptor,
|
||||
shared: NonNullable<typeof sharedScreen>,
|
||||
): Promise<void> {
|
||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||||
remotePanelStatus(surface.screenId, "connecting");
|
||||
try {
|
||||
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([
|
||||
import("./media/remoteMedia.ts"),
|
||||
import("./media/iceClient.ts"),
|
||||
]);
|
||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||||
const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch });
|
||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||||
const remote = createRemoteOfficeMedia({
|
||||
role: "presenter",
|
||||
binding: screenBinding(surface),
|
||||
authenticatedFetch: authFetch,
|
||||
peerConnectionConfiguration: ice.configuration,
|
||||
onStateChange(state) {
|
||||
if (sharedScreen !== shared) return;
|
||||
if (state.status === "live") remotePanelStatus(surface.screenId, "live");
|
||||
else if (state.status === "reconnecting") remotePanelStatus(surface.screenId, "reconnecting");
|
||||
else if (state.status === "connecting") remotePanelStatus(surface.screenId, "connecting");
|
||||
else if (state.status === "stopped" || state.status === "revoked") {
|
||||
showDetail("The hosted screen share ended.");
|
||||
queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (sharedScreen !== shared) return;
|
||||
if (isRemoteAuthorizationError(error)) {
|
||||
showDetail("Screen sharing stopped because authorization ended.");
|
||||
queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); });
|
||||
} else {
|
||||
remotePanelStatus(surface.screenId, "reconnecting");
|
||||
}
|
||||
},
|
||||
});
|
||||
shared.remote = remote;
|
||||
await remote.startPresenter({ consent: { authorized: true, optedIn: true }, stream: shared.stream, video: shared.video });
|
||||
if (sharedScreen === shared) showDetail(`Sharing ${surface.screenId} locally and to authorized remote viewers.`);
|
||||
} catch (error) {
|
||||
if (sharedScreen !== shared) return;
|
||||
if (isRemoteAuthorizationError(error)) {
|
||||
showDetail("Screen sharing stopped because authorization ended. Sign in again to share.");
|
||||
stopLocalScreenShare();
|
||||
return;
|
||||
}
|
||||
void shared.remote?.dispose().catch(() => undefined);
|
||||
shared.remote = null;
|
||||
remotePanelStatus(surface.screenId, "unavailable");
|
||||
showDetail(`Sharing locally to ${surface.screenId}; hosted sharing is unavailable.`);
|
||||
}
|
||||
}
|
||||
|
||||
function stopLocalScreenShare(): void {
|
||||
const shared = sharedScreen;
|
||||
if (!shared) return;
|
||||
sharedScreen = null;
|
||||
remoteMediaOperation += 1;
|
||||
office?.clearMediaSurface(shared.screenId);
|
||||
remotePanelStatus(shared.screenId, "stopped");
|
||||
void shared.remote?.stop().catch(() => undefined);
|
||||
for (const track of shared.stream.getTracks()) track.stop();
|
||||
shared.texture.dispose();
|
||||
shared.video.pause();
|
||||
@@ -2281,6 +2490,7 @@ function stopLocalScreenShare(): void {
|
||||
|
||||
function disposeOfficeScreenUi(): void {
|
||||
stopLocalScreenShare();
|
||||
releaseRemoteViewer("off", "dispose");
|
||||
officeScreenPanel?.dispose();
|
||||
officeScreenPanel = null;
|
||||
if (screensOverlay) screensOverlay.hidden = true;
|
||||
@@ -2288,11 +2498,9 @@ function disposeOfficeScreenUi(): void {
|
||||
|
||||
async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
|
||||
if (!office || !inside || office.depth !== "full") return;
|
||||
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
|
||||
if (!optedIn) {
|
||||
showDetail("Opt in to view this screen before starting a local preview.");
|
||||
return;
|
||||
}
|
||||
// One authored surface role at a time. A viewer texture must not survive
|
||||
// behind a new presenter preview or be cleared later over the presenter.
|
||||
releaseRemoteViewer("off");
|
||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||
showDetail("This browser does not provide tab or window sharing.");
|
||||
return;
|
||||
@@ -2331,7 +2539,8 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
|
||||
showDetail("That screen is no longer available.");
|
||||
return;
|
||||
}
|
||||
sharedScreen = { screenId: surface.screenId, stream, video, texture };
|
||||
const shared = { screenId: surface.screenId, stream, video, texture, remote: null };
|
||||
sharedScreen = shared;
|
||||
pendingStream = null;
|
||||
pendingVideo = null;
|
||||
pendingTexture = null;
|
||||
@@ -2339,6 +2548,9 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
|
||||
officeScreenPanel?.update(office.listMediaSurfaces());
|
||||
renderOfficeBadge();
|
||||
showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`);
|
||||
// Anonymous/self-host-only behavior ends here exactly as before. The
|
||||
// transport chunk is fetched only for a signed-in explicit share.
|
||||
if (access.subject !== null) void startRemotePresenter(surface, shared);
|
||||
} catch (error) {
|
||||
for (const track of pendingStream?.getTracks() ?? []) track.stop();
|
||||
pendingTexture?.dispose();
|
||||
@@ -2362,9 +2574,28 @@ function ensureOfficeScreenPanel(): OfficeScreenPanel | null {
|
||||
container: screensOverlay,
|
||||
surfaces: office.listMediaSurfaces(),
|
||||
onRequestShare: (surface) => { void startLocalScreenShare(surface); },
|
||||
onStopShare: () => stopLocalScreenShare(),
|
||||
onSelect(surface) {
|
||||
if ((remoteViewedScreen && remoteViewedScreen.screenId !== surface.screenId) ||
|
||||
(remoteViewerRequestScreenId !== null && remoteViewerRequestScreenId !== surface.screenId)) {
|
||||
releaseRemoteViewer("off");
|
||||
}
|
||||
},
|
||||
onStopShare: (surface) => {
|
||||
if (sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
|
||||
if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) {
|
||||
releaseRemoteViewer("stopped");
|
||||
}
|
||||
},
|
||||
onViewerOptIn(surface, optedIn) {
|
||||
if (!optedIn && sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
|
||||
if (!optedIn) {
|
||||
if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) {
|
||||
releaseRemoteViewer("off");
|
||||
} else {
|
||||
remotePanelStatus(surface.screenId, "off");
|
||||
}
|
||||
} else if (access.subject !== null) {
|
||||
void startRemoteViewer(surface);
|
||||
}
|
||||
},
|
||||
});
|
||||
const syncOverlay = () => queueMicrotask(() => {
|
||||
|
||||
Reference in New Issue
Block a user