1import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { createNativeStackNavigator } from "@react-navigation/native-stack";
3import {
4 EnvironmentId,
5 FILL_PREVIEW_VIEWPORT,
6 ThreadId,
7 type PreviewSessionSnapshot,
8} from "@t3tools/contracts";
9import { normalizePreviewUrl } from "@t3tools/shared/preview";
10import { useCallback, useEffect, useRef, useState } from "react";
11import { ActivityIndicator, Alert, AppState, Platform, TextInput, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
13
14import { ControlPill } from "../../components/ControlPill";
15import { ScreenHeader } from "../../components/ScreenHeader";
16import { NativeHeaderToolbar } from "../../native/StackHeader";
17import { useThreadServerBrowserTabs } from "../../state/preview";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
19import { BrowserTabMenu } from "./BrowserTabMenu";
20import { browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs";
21import {
22 PreviewStreamWebView,
23 type PreviewPictureInPictureState,
24 type PreviewStreamRef,
25} from "./PreviewStreamWebView";
26
27const BrowserPreviewStack = createNativeStackNavigator<{ BrowserPreview: undefined }>();
28
29const NO_PICTURE_IN_PICTURE: PreviewPictureInPictureState = { supported: false, active: false };
30
31type BrowserPreviewRouteScreenProps = StaticScreenProps<{
32 readonly environmentId: string;
33 readonly threadId: string;
34 readonly tabId?: string;
35}>;
36
37/** The nested native stack supplies the navigation bar inside the modal. */
38export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) {
39 const navigation = useNavigation();
40 const onClose = useCallback(() => navigation.goBack(), [navigation]);
41 return (
42 <View collapsable={false} className="flex-1 bg-sheet">
43 <BrowserPreviewStack.Navigator
44 screenOptions={{
45 headerShown: Platform.OS === "ios",
46 headerBackVisible: false,
47 headerShadowVisible: false,
48 headerTransparent: false,
49 headerTitleStyle: { fontSize: 17, fontWeight: "600" },
50 }}
51 >
52 <BrowserPreviewStack.Screen name="BrowserPreview">
53 {() => (
54 <BrowserPreviewScreen
55 environmentId={EnvironmentId.make(route.params.environmentId)}
56 threadId={ThreadId.make(route.params.threadId)}
57 initialTabId={route.params.tabId ?? null}
58 onClose={onClose}
59 />
60 )}
61 </BrowserPreviewStack.Screen>
62 </BrowserPreviewStack.Navigator>
63 </View>
64 );
65}
66
67function BrowserPreviewScreen({
68 environmentId,
69 threadId,
70 initialTabId,
71 onClose,
72}: {
73 readonly environmentId: EnvironmentId;
74 readonly threadId: ThreadId;
75 readonly initialTabId: string | null;
76 readonly onClose: () => void;
77}) {
78 const insets = useSafeAreaInsets();
79 const { themeVariables } = useAppearancePreferences();
80 const focused = useIsFocused();
81 const [foreground, setForeground] = useState(AppState.currentState !== "background");
82 const [selectedTabId, setSelectedTabId] = useState(initialTabId);
83 const [pictureInPicture, setPictureInPicture] = useState(NO_PICTURE_IN_PICTURE);
84 // Address bar commands only reach a page that is streaming.
85 const [streaming, setStreaming] = useState(false);
86 const [canControl, setCanControl] = useState(false);
87 const streamRef = useRef<PreviewStreamRef>(null);
88 const { tabs, loaded } = useThreadServerBrowserTabs({ environmentId, threadId, enabled: true });
89 const tab = tabs.find((entry) => entry.tabId === selectedTabId) ?? latestBrowserTab(tabs);
90 const tabId = tab?.tabId ?? null;
91 // Pin the fallback so another tab's activity does not switch the view.
92 if (tabId !== null && tabId !== selectedTabId) {
93 setPictureInPicture(NO_PICTURE_IN_PICTURE);
94 setSelectedTabId(tabId);
95 }
96 useEffect(() => {
97 const subscription = AppState.addEventListener("change", (state) =>
98 setForeground(state !== "background"),
99 );
100 return () => subscription.remove();
101 }, []);
102 useEffect(() => {
103 if (focused && loaded && tabs.length === 0) onClose();
104 }, [focused, loaded, tabs.length, onClose]);
105 const onPictureInPicture = useCallback((state: PreviewPictureInPictureState, detail?: string) => {
106 setPictureInPicture({ supported: state.supported, active: state.active });
107 if (detail) Alert.alert("Picture in picture is unavailable", detail);
108 }, []);
109 const selectTab = (next: string) => {
110 setPictureInPicture(NO_PICTURE_IN_PICTURE);
111 setSelectedTabId(next);
112 };
113 // System picture in picture keeps showing this stream after the app leaves the foreground.
114 const live = (focused && foreground) || pictureInPicture.active;
115
116 return (
117 <View className="flex-1 bg-sheet" style={{ paddingBottom: insets.bottom }}>
118 <ScreenHeader
119 title={tab ? browserTabTitle(tab) : "Browser"}
120 sidebar={false}
121 onBack={onClose}
122 options={{ headerBackVisible: false }}
123 actions={
124 // Android WebView cannot play video in picture in picture.
125 Platform.OS === "ios" && pictureInPicture.supported
126 ? [
127 {
128 accessibilityLabel: pictureInPicture.active
129 ? "Exit picture in picture"
130 : "Picture in picture",
131 icon: {
132 ios: pictureInPicture.active ? "pip.exit" : "pip.enter",
133 android: "visibility",
134 },
135 selected: pictureInPicture.active,
136 onPress: () => streamRef.current?.togglePictureInPicture(),
137 },
138 ]
139 : []
140 }
141 menus={
142 tabs.length > 1
143 ? [
144 {
145 title: "Tabs",
146 icon: "square.on.square",
147 items: tabs.map((entry) => ({
148 id: entry.tabId,
149 title: browserTabTitle(entry),
150 subtitle: browserTabUrl(entry),
151 selected: entry.tabId === tabId,
152 onPress: () => selectTab(entry.tabId),
153 })),
154 },
155 ]
156 : []
157 }
158 />
159 {Platform.OS === "ios" ? (
160 <NativeHeaderToolbar placement="left">
161 <NativeHeaderToolbar.Button
162 icon="xmark"
163 accessibilityLabel="Close browser"
164 onPress={onClose}
165 separateBackground
166 />
167 </NativeHeaderToolbar>
168 ) : null}
169 {tab ? (
170 <>
171 <BrowserAddressBar
172 key={`address:${tab.tabId}`}
173 environmentId={environmentId}
174 tab={tab}
175 streaming={streaming}
176 ready={streaming && canControl}
177 onCommand={(input) => streamRef.current?.command(input)}
178 />
179 {live ? (
180 <PreviewStreamWebView
181 key={`stream:${tab.tabId}`}
182 environmentId={environmentId}
183 threadId={threadId}
184 tabId={tab.tabId}
185 ref={streamRef}
186 interactive
187 background={themeVariables["--color-sheet-solid"]}
188 onPictureInPicture={onPictureInPicture}
189 onStreamingChange={setStreaming}
190 onControl={(control) => setCanControl(control?.controller === "you")}
191 />
192 ) : (
193 <View className="flex-1" />
194 )}
195 </>
196 ) : (
197 <View className="flex-1 items-center justify-center">
198 {focused && foreground ? (
199 <ActivityIndicator color={themeVariables["--color-icon"]} />
200 ) : null}
201 </View>
202 )}
203 </View>
204 );
205}
206
207function BrowserAddressBar({
208 environmentId,
209 tab,
210 ready,
211 streaming,
212 onCommand,
213}: {
214 readonly environmentId: EnvironmentId;
215 readonly tab: PreviewSessionSnapshot;
216 /** The viewer controls the page, so navigation and reload apply. */
217 readonly ready: boolean;
218 /** The menu's changes need no control, only a live tab. */
219 readonly streaming: boolean;
220 readonly onCommand: PreviewStreamRef["command"];
221}) {
222 const url = browserTabUrl(tab);
223 // Null while not editing, so agent navigation keeps the field current.
224 const [draft, setDraft] = useState<string | null>(null);
225 const submit = () => {
226 const value = draft ?? url;
227 setDraft(null);
228 try {
229 onCommand({ type: "navigate", url: normalizePreviewUrl(value) });
230 } catch {
231 Alert.alert("Could not open this address", "Enter an http or https URL.");
232 }
233 };
234 return (
235 <View className="flex-row items-center gap-1 px-2 pb-2">
236 <ControlPill
237 icon="chevron.left"
238 accessibilityLabel="Back"
239 disabled={!ready || !tab.canGoBack}
240 onPress={() => onCommand({ type: "history", delta: -1 })}
241 />
242 <ControlPill
243 icon="chevron.right"
244 accessibilityLabel="Forward"
245 disabled={!ready || !tab.canGoForward}
246 onPress={() => onCommand({ type: "history", delta: 1 })}
247 />
248 <TextInput
249 accessibilityLabel="Address"
250 editable={ready}
251 value={draft ?? url}
252 onChangeText={setDraft}
253 onBlur={() => setDraft(null)}
254 onSubmitEditing={submit}
255 placeholder="Enter a URL"
256 placeholderTextColorClassName="accent-placeholder"
257 autoCapitalize="none"
258 autoCorrect={false}
259 keyboardType="url"
260 returnKeyType="go"
261 selectTextOnFocus
262 className="h-10 min-w-0 flex-1 rounded-full border border-input-border bg-input px-4 font-sans text-sm text-foreground"
263 />
264 <ControlPill
265 icon="arrow.clockwise"
266 accessibilityLabel="Reload page"
267 disabled={!ready}
268 onPress={() => onCommand({ type: "reload" })}
269 />
270 <BrowserTabMenu environmentId={environmentId} tab={tab} disabled={!streaming} />
271 </View>
272 );
273}