Skip to content
Source
Sign inSign up
Open History to see the last commit

serverPictureInPicture.ts

198 lines6.6 KB

1import {
2 createPreviewFramePainter,
3 createPreviewStreamClient,
4 type PreviewStreamClient,
5} from "@t3tools/client-runtime/preview/server-browser-stream";
6import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
7import { scopedThreadKey } from "@t3tools/client-runtime/environment";
8import { previewPdfAssetUrl } from "@t3tools/shared/preview";
9import { useSyncExternalStore } from "react";
10​
11import { readPreviewStreamAccess } from "~/state/previewStream";
12import { previewStateAtom, type ThreadPreviewState } from "~/previewStateStore";
13import { appAtomRegistry } from "~/rpc/atomRegistry";
14​
15interface WebKitVideo {
16 webkitSetPresentationMode?: (mode: string) => void;
17 webkitPresentationMode?: string;
19​
20interface ActivePictureInPicture {
21 readonly key: string;
22 readonly stop: () => void;
24​
25// Owns its stream outside React so the floating window survives panel unmounts.
26let active: ActivePictureInPicture | null = null;
27const listeners = new Set<() => void>();
28​
29const emit = () => {
30 for (const listener of listeners) listener();
31};
32​
33const subscribe = (listener: () => void) => {
34 listeners.add(listener);
35 return () => listeners.delete(listener);
36};
37​
38export const serverPictureInPictureKey = (threadId: string, tabId: string) =>
39 JSON.stringify([threadId, tabId]);
40​
41export function supportsServerPictureInPicture(): boolean {
42 if (typeof document === "undefined") return false;
43 if (typeof HTMLCanvasElement.prototype.captureStream !== "function") return false;
44 const video = HTMLVideoElement.prototype as HTMLVideoElement & WebKitVideo;
45 return (
46 (document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function") ||
47 typeof video.webkitSetPresentationMode === "function"
48 );
50​
51export function useServerPictureInPictureKey(): string | null {
52 return useSyncExternalStore(
53 subscribe,
54 () => active?.key ?? null,
55 () => null,
56 );
58​
59export function closeServerPictureInPicture(): void {
60 const current = active;
61 active = null;
62 current?.stop();
63 emit();
65​
66/** Open during user activation, seeding the video with the currently visible frame. */
67export async function openServerPictureInPicture(input: {
68 readonly environmentId: EnvironmentId;
69 readonly threadId: ThreadId;
70 readonly tabId: string;
71 readonly seed: HTMLCanvasElement | null;
72}): Promise<void> {
73 closeServerPictureInPicture();
74 const canvas = document.createElement("canvas");
75 const context = canvas.getContext("2d");
76 const seed = input.seed && input.seed.width > 0 && input.seed.height > 0 ? input.seed : null;
77 canvas.width = seed?.width ?? 640;
78 canvas.height = seed?.height ?? 400;
79 // The capture emits a frame per draw, so the seed is drawn after it starts.
80 const stream = canvas.captureStream();
81 if (seed) context?.drawImage(seed, 0, 0);
82 else context?.fillRect(0, 0, canvas.width, canvas.height);
83 const video = document.createElement("video") as HTMLVideoElement & WebKitVideo;
84 video.muted = true;
85 video.playsInline = true;
86 video.srcObject = stream;
87 // Safari only floats a video that is in the document.
88 video.style.cssText =
89 "position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:0;pointer-events:none";
90 document.body.append(video);
91​
92 const painter = createPreviewFramePainter(canvas);
93 let client: PreviewStreamClient | null = null;
94 let stopped = false;
95 let refusals = 0;
96 let retryTimer: ReturnType<typeof setTimeout> | null = null;
97 let stopWatchingPreview = () => {};
98 const onLeave = () => {
99 if (active === current) closeServerPictureInPicture();
100 };
101 const connect = async (refresh: boolean) => {
102 const access = await readPreviewStreamAccess(input.environmentId, refresh);
103 if (stopped) return;
104 if (!access) {
105 closeServerPictureInPicture();
106 return;
107 }
108 client = createPreviewStreamClient(
109 {
110 access,
111 threadId: input.threadId,
112 tabId: input.tabId,
113 maxWidth: 1280,
114 maxHeight: 1280,
115 interactive: false,
116 },
117 {
118 onFrame: (jpeg) => {
119 refusals = 0;
120 painter.paint(jpeg);
121 },
122 onViewport: () => undefined,
123 onConnectedChange: () => undefined,
124 onUnauthorized: () => {
125 if (stopped) return;
126 if (refusals >= 5) {
127 onLeave();
128 return;
129 }
130 retryTimer = setTimeout(() => void connect(true), 1_000 * 2 ** refusals++);
131 },
132 onGone: onLeave,
133 },
134 );
135 };
136​
137 const onPresentationMode = () => {
138 if (video.webkitPresentationMode !== "picture-in-picture") onLeave();
139 };
140 const current: ActivePictureInPicture = {
141 key: serverPictureInPictureKey(input.threadId, input.tabId),
142 stop: () => {
143 stopped = true;
144 stopWatchingPreview();
145 if (retryTimer !== null) clearTimeout(retryTimer);
146 client?.stop();
147 painter.stop();
148 video.removeEventListener("leavepictureinpicture", onLeave);
149 video.removeEventListener("webkitpresentationmodechanged", onPresentationMode);
150 if (document.pictureInPictureElement === video) {
151 void document.exitPictureInPicture().catch(() => undefined);
152 } else if (video.webkitPresentationMode === "picture-in-picture") {
153 video.webkitSetPresentationMode?.("inline");
154 }
155 for (const track of stream.getTracks()) track.stop();
156 video.srcObject = null;
157 video.remove();
158 },
159 };
160 active = current;
161 emit();
162​
163 try {
164 await video.play();
165 if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
166 await new Promise((resolve) =>
167 video.addEventListener("loadedmetadata", resolve, { once: true }),
168 );
169 }
170 if (document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function") {
171 video.addEventListener("leavepictureinpicture", onLeave);
172 await video.requestPictureInPicture();
173 } else {
174 video.addEventListener("webkitpresentationmodechanged", onPresentationMode);
175 video.webkitSetPresentationMode?.("picture-in-picture");
176 }
177 } catch (error) {
178 if (stopped) return;
179 onLeave();
180 throw error;
181 }
182 if (active === current) {
183 const atom = previewStateAtom(scopedThreadKey(input));
184 const closeForPdf = (state: ThreadPreviewState) => {
185 const navigation = state.sessions[input.tabId]?.navStatus;
186 if (
187 navigation?._tag === "Success" &&
188 previewPdfAssetUrl(navigation.url, navigation.url) !== null
189 ) {
190 onLeave();
191 }
192 };
193 // PDFs render in the client and cannot appear in the server's video stream.
194 stopWatchingPreview = appAtomRegistry.subscribe(atom, closeForPdf);
195 closeForPdf(appAtomRegistry.get(atom));
196 if (active === current) void connect(false);
197 }