1import { useIsFocused } from "@react-navigation/native";
2import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";
3import { useCallback, useEffect, useRef, useState } from "react";
4import { AppState, Pressable, View } from "react-native";
5import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated";
6
7import { SymbolView } from "../../components/AppSymbol";
8import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
9import { browserTabTitle } from "./browserTabs";
10import { PreviewStreamWebView } from "./PreviewStreamWebView";
11
12const PLAYER_LONG_SIDE = 184;
13const PLAYER_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
14const PLAYER_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
15
16export function ThreadBrowserFloat(props: {
17 readonly environmentId: EnvironmentId;
18 readonly threadId: ThreadId;
19 readonly tabs: ReadonlyArray<PreviewSessionSnapshot>;
20 readonly loaded: boolean;
21 readonly top: number;
22 readonly onOpen: (tabId: string) => void;
23}) {
24 const [tabId, setTabId] = useState<string | null>(null);
25 const known = useRef<ReadonlyMap<string, string | undefined> | null>(null);
26 useEffect(() => {
27 if (!props.loaded) return;
28 const previous = known.current;
29 known.current = new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id]));
30 // The first list is a baseline, so reopening a thread does not resurface old tabs.
31 if (previous === null) return;
32 const opened = props.tabs.findLast(
33 (tab) =>
34 tab.reveal === true &&
35 (tab.revealRequest
36 ? previous.get(tab.tabId) !== tab.revealRequest.id
37 : !previous.has(tab.tabId)),
38 );
39 if (opened !== undefined) setTabId(opened.tabId);
40 }, [props.loaded, props.tabs]);
41 const focused = useIsFocused();
42 const [foreground, setForeground] = useState(AppState.currentState !== "background");
43 useEffect(() => {
44 const subscription = AppState.addEventListener("change", (state) =>
45 setForeground(state !== "background"),
46 );
47 return () => subscription.remove();
48 }, []);
49 const tab = props.tabs.find((entry) => entry.tabId === tabId) ?? null;
50 const onOpen = props.onOpen;
51 const open = useCallback(() => {
52 if (tabId === null) return;
53 setTabId(null);
54 onOpen(tabId);
55 }, [onOpen, tabId]);
56 const close = useCallback(() => setTabId(null), []);
57 if (!tab || !focused) return null;
58 return (
59 <FloatingBrowserPlayer
60 environmentId={props.environmentId}
61 threadId={props.threadId}
62 tab={tab}
63 live={foreground}
64 top={props.top}
65 onOpen={open}
66 onClose={close}
67 />
68 );
69}
70
71function FloatingBrowserPlayer(props: {
72 readonly environmentId: EnvironmentId;
73 readonly threadId: ThreadId;
74 readonly tab: PreviewSessionSnapshot;
75 readonly live: boolean;
76 readonly top: number;
77 readonly onOpen: () => void;
78 readonly onClose: () => void;
79}) {
80 const { themeVariables } = useAppearancePreferences();
81 const [viewport, setViewport] = useState<{ width: number; height: number } | null>(null);
82 // The page keeps its own size; the player scales it into a box of the same shape.
83 const aspect = Math.min(Math.max(viewport ? viewport.width / viewport.height : 16 / 10, 0.5), 2);
84 const width = aspect >= 1 ? PLAYER_LONG_SIDE : Math.round(PLAYER_LONG_SIDE * aspect);
85 const height = Math.round(width / aspect);
86 const background = themeVariables["--color-sheet-solid"];
87 return (
88 <Animated.View
89 entering={PLAYER_ENTERING}
90 exiting={PLAYER_EXITING}
91 className="absolute right-3 z-30 overflow-hidden rounded-2xl border border-border shadow-md shadow-black/20"
92 style={{ top: props.top, width, height, backgroundColor: background }}
93 >
94 <Pressable
95 accessibilityRole="button"
96 accessibilityLabel={`Open browser, ${browserTabTitle(props.tab)}`}
97 accessibilityHint="Opens the browser tab full screen"
98 onPress={props.onOpen}
99 className="flex-1"
100 >
101 <View pointerEvents="none" className="flex-1">
102 <PreviewStreamWebView
103 environmentId={props.environmentId}
104 threadId={props.threadId}
105 tabId={props.tab.tabId}
106 interactive={false}
107 background={background}
108 compact
109 paused={!props.live}
110 onViewport={setViewport}
111 onGone={props.onClose}
112 />
113 </View>
114 </Pressable>
115 <Pressable
116 accessibilityRole="button"
117 accessibilityLabel="Close floating preview"
118 hitSlop={8}
119 onPress={props.onClose}
120 className="absolute right-1.5 top-1.5 size-6 items-center justify-center rounded-full bg-black/55"
121 >
122 <SymbolView name="xmark" size={11} tintColor="#ffffff" type="monochrome" />
123 </Pressable>
124 </Animated.View>
125 );
126}