Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 41 additions & 40 deletions apps/mobile/src/features/browser/PreviewStreamWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from "@t3tools/client-runtime/preview/server-browser-stream";
import type { EnvironmentId, PreviewStreamHostSetup } from "@t3tools/contracts";
import {
useEffect,
useEffectEvent,
useImperativeHandle,
useLayoutEffect,
Expand Down Expand Up @@ -52,11 +51,13 @@ export interface PreviewPictureInPictureState {
type NativeStreamBridge = {
readonly ref?: Ref<PreviewStreamRef>;
readonly onUnauthorized: () => void;
/** Asks for fresh access after a refusal, once `delayMs` has passed. */
readonly onRefused: (delayMs: number) => void;
readonly onGone?: () => void;
readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void;
readonly onControl?: (control: PreviewStreamControl | null) => void;
readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void;
/** True while frames show, so commands reach the page. Pass a stable function. */
/** True while frames show, so commands reach the page. */
readonly onStreamingChange?: (streaming: boolean) => void;
readonly compact?: boolean;
readonly pdfUri?: string | null;
Expand Down Expand Up @@ -113,14 +114,21 @@ const MAX_REFUSALS = 3;

export function PreviewStreamWebView(
props: Omit<PreviewStreamConfiguration, "access"> &
Omit<NativeStreamBridge, "onUnauthorized"> & {
Omit<NativeStreamBridge, "onUnauthorized" | "onRefused"> & {
readonly environmentId: EnvironmentId;
readonly paused?: boolean;
},
) {
const { access, error, refresh } = usePreviewStreamAccess(props.environmentId);
const { access, error, refresh, retryAfter } = usePreviewStreamAccess(props.environmentId);
if (access && !props.paused) {
return <AuthorizedPreviewStream {...props} access={access} onUnauthorized={refresh} />;
return (
<AuthorizedPreviewStream
{...props}
access={access}
onUnauthorized={refresh}
onRefused={retryAfter}
/>
);
}
return (
<View
Expand Down Expand Up @@ -165,13 +173,6 @@ function AuthorizedPreviewStream({
}
const processRetried = useRef(false);
const unauthorized = useRef(0);
const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
},
[],
);
const configuration = JSON.stringify({
access: props.access,
threadId: props.threadId,
Expand All @@ -188,14 +189,7 @@ function AuthorizedPreviewStream({
onUnauthorized={() => {
const refusals = ++unauthorized.current;
if (refusals >= MAX_REFUSALS) return false;
if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
refreshTimer.current = setTimeout(
() => {
refreshTimer.current = null;
props.onUnauthorized();
},
refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1),
);
props.onRefused(refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1));
return true;
}}
onRetry={() => {
Expand Down Expand Up @@ -232,7 +226,7 @@ function PreviewStreamDocumentView({
onRetry,
onStreaming,
onRecoverProcess,
}: Omit<NativeStreamBridge, "onUnauthorized"> & {
}: Omit<NativeStreamBridge, "onUnauthorized" | "onRefused"> & {
readonly configuration: string;
readonly background: string;
readonly onUnauthorized: () => boolean;
Expand All @@ -241,13 +235,13 @@ function PreviewStreamDocumentView({
readonly onRecoverProcess: () => boolean;
}) {
const webView = useRef<WebView<object>>(null);
const active = useRef(true);
const active = useRef(false);
const failed = useRef(false);
const bootstrapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting");
const [error, setError] = useState<string | null>(null);
const [gone, setGone] = useState(false);
const [hostSetup, setHostSetup] = useState<PreviewStreamHostSetup | null>(null);
const [started, setStarted] = useState(false);
const [control, setControl] = useState<PreviewStreamControl | null>(null);
const dialogRef = useRef<PreviewStreamControl["dialog"]>(null);
const [promptText, setPromptText] = useState("");
Expand All @@ -263,7 +257,6 @@ function PreviewStreamDocumentView({
),
);
};
const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next));
const command = (input: PreviewStreamInput) =>
webView.current?.injectJavaScript(
`window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`,
Expand All @@ -277,9 +270,15 @@ function PreviewStreamDocumentView({
},
() => Dialog.alert("Could not read the clipboard"),
);
const clearBootstrapTimer = () => {
if (bootstrapTimer.current === null) return;
clearTimeout(bootstrapTimer.current);
bootstrapTimer.current = null;
};
const fail = (message: string) => {
if (!active.current || failed.current) return;
failed.current = true;
clearBootstrapTimer();
webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
onStreamingChange?.(false);
setControl(null);
Expand All @@ -291,11 +290,23 @@ function PreviewStreamDocumentView({
const bootstrapTimedOut = useEffectEvent(() =>
fail("Browser viewer could not start. Reconnect to try again."),
);
useEffect(() => {
if (started) return;
const timer = setTimeout(bootstrapTimedOut, 15_000);
return () => clearTimeout(timer);
}, [started]);
const streamEnded = useEffectEvent(() => {
onStreamingChange?.(false);
onControl?.(null);
});
// The stream lives exactly as long as this view.
useLayoutEffect(() => {
active.current = true;
const view = webView.current;
bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000);
return () => {
if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current);
bootstrapTimer.current = null;
active.current = false;
view?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
streamEnded();
};
}, []);
const source = useMemo(
() => ({
html: previewStreamDocument(configuration, previewStreamScript),
Expand All @@ -309,16 +320,6 @@ function PreviewStreamDocumentView({
togglePictureInPicture: () =>
webView.current?.injectJavaScript("window.T3PreviewStream?.pictureInPicture(); true;"),
}));
useLayoutEffect(() => {
active.current = true;
const view = webView.current;
return () => {
active.current = false;
view?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
};
}, []);
useEffect(() => () => onStreamingChange?.(false), [onStreamingChange]);
useEffect(() => () => controlChanged(null), []);
const processTerminated = () => {
if (!active.current || failed.current) return;
if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again.");
Expand Down Expand Up @@ -407,7 +408,7 @@ function PreviewStreamDocumentView({
onPictureInPicture?.(message, message.detail);
return;
case "status":
setStarted(true);
clearBootstrapTimer();
if (message.status === "error") {
fail(message.detail ?? "Browser stream failed.");
return;
Expand Down
39 changes: 24 additions & 15 deletions apps/mobile/src/features/browser/ThreadBrowserFloat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useIsFocused } from "@react-navigation/native";
import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";
import { previewPdfAssetUrl } from "@t3tools/shared/preview";
import * as Option from "effect/Option";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useState } from "react";
import { Pressable, View } from "react-native";
import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated";

Expand All @@ -26,21 +26,30 @@ export function ThreadBrowserFloat(props: {
readonly onOpen: (tabId: string) => void;
}) {
const [tabId, setTabId] = useState<string | null>(null);
const known = useRef<ReadonlyMap<string, string | undefined> | null>(null);
useEffect(() => {
if (!props.loaded) return;
const previous = known.current;
known.current = new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id]));
if (previous === null) return;
const opened = props.tabs.findLast(
(tab) =>
tab.reveal === true &&
(tab.revealRequest
? previous.get(tab.tabId) !== tab.revealRequest.id
: !previous.has(tab.tabId)),
);
// The reveal requests already seen, so only a tab revealed after the first
// list pops the player.
const [seen, setSeen] = useState<{
readonly tabs: ReadonlyArray<PreviewSessionSnapshot>;
readonly reveals: ReadonlyMap<string, string | undefined>;
} | null>(null);
if (props.loaded && seen?.tabs !== props.tabs) {
setSeen({
tabs: props.tabs,
reveals: new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])),
});
const previous = seen?.reveals;
const opened =
previous === undefined
? undefined
: props.tabs.findLast(
(tab) =>
tab.reveal === true &&
(tab.revealRequest
? previous.get(tab.tabId) !== tab.revealRequest.id
: !previous.has(tab.tabId)),
);
if (opened !== undefined) setTabId(opened.tabId);
}, [props.loaded, props.tabs]);
}
const focused = useIsFocused();
const foreground = useAppForeground();
const tab = props.tabs.find((entry) => entry.tabId === tabId) ?? null;
Expand Down
25 changes: 10 additions & 15 deletions apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import * as Haptics from "expo-haptics";
import { Accelerometer } from "expo-sensors";
import { Pressable, StatusBar, View } from "react-native";
Expand All @@ -31,7 +31,8 @@ import { useAppForeground } from "../../lib/appState";
import { useHardwareBack } from "../../lib/backHandler";
import { deriveBottomControlInsets } from "../../lib/reserved-regions";
import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device";
import { uuidv4 } from "../../lib/uuid";
import { deviceEnvironment, useDeviceHubAccess } from "../../state/device";
import { useEnvironmentQuery } from "../../state/query";
import { useAtomCommand } from "../../state/use-atom-command";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
Expand Down Expand Up @@ -102,13 +103,10 @@ export function DevicePreviewScreen({
const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} }));
const previews = threadDevicePreviews(state.data, threadId);
const preview = selectedThreadDevicePreview(previews, selectedKey);
const onInputConnected = useCallback(
async (connected: boolean) => {
setInputConnected(connected);
if (connected && !embedded) setControlsVisible(false);
},
[embedded],
);
const onInputConnected = async (connected: boolean) => {
setInputConnected(connected);
if (connected && !embedded) setControlsVisible(false);
};
useEffect(() => {
if (focused && state.data !== null && previews.length === 0) onClose();
}, [focused, state.data, previews.length, onClose]);
Expand Down Expand Up @@ -401,12 +399,9 @@ function OpenDevicePreview({
}) {
const { session } = preview;
const { themeVariables } = useAppearancePreferences();
const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId);
const onUnauthorized = async () => refreshDeviceHubAccess(environmentId);
useEffect(() => {
refreshDeviceHubAccess(environmentId);
return () => void onInputConnected(false);
}, [environmentId, onInputConnected]);
const [viewer] = useState(uuidv4);
const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId, viewer);
const onUnauthorized = async () => refresh();
return access ? (
<DeviceStreamWebView
ref={streamRef}
Expand Down
43 changes: 24 additions & 19 deletions apps/mobile/src/features/devices/DeviceStreamWebView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Button } from "../../components/ui/Button";
import deviceStreamScript from "@t3tools/mobile-device-stream";
import {
useEffect,
useEffectEvent,
useImperativeHandle,
useLayoutEffect,
Expand Down Expand Up @@ -95,14 +94,20 @@ function DeviceStreamDocumentView({
readonly onRecoverProcess: () => boolean;
}) {
const webView = useRef<WebView<object>>(null);
const active = useRef(true);
const active = useRef(false);
const failed = useRef(false);
const bootstrapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = useState<DeviceStreamStatus>("connecting");
const [error, setError] = useState<string | null>(null);
const [started, setStarted] = useState(false);
const clearBootstrapTimer = () => {
if (bootstrapTimer.current === null) return;
clearTimeout(bootstrapTimer.current);
bootstrapTimer.current = null;
};
const fail = (message: string) => {
if (!active.current || failed.current) return;
failed.current = true;
clearBootstrapTimer();
void onInputConnected(false);
webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;");
setError(message);
Expand All @@ -111,11 +116,21 @@ function DeviceStreamDocumentView({
const bootstrapTimedOut = useEffectEvent(() =>
fail("Device viewer could not start. Reconnect to try again."),
);
useEffect(() => {
if (started) return;
const timer = setTimeout(bootstrapTimedOut, 15_000);
return () => clearTimeout(timer);
}, [started]);
const resetInput = useEffectEvent(() => void onInputConnected(false));
// The stream lives exactly as long as this view.
useLayoutEffect(() => {
active.current = true;
resetInput();
const view = webView.current;
bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000);
return () => {
if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current);
bootstrapTimer.current = null;
active.current = false;
view?.injectJavaScript("window.T3DeviceStream?.stop(); true;");
resetInput();
};
}, []);
const source = {
html: deviceStreamDocument(configuration, deviceStreamScript),
baseUrl,
Expand All @@ -131,16 +146,6 @@ function DeviceStreamDocumentView({
appSwitcher: () => command("appSwitcher"),
rotate: () => command("rotate"),
}));
const resetInput = useEffectEvent(() => void onInputConnected(false));
useLayoutEffect(() => {
active.current = true;
resetInput();
const view = webView.current;
return () => {
active.current = false;
view?.injectJavaScript("window.T3DeviceStream?.stop(); true;");
};
}, []);
const processTerminated = () => {
if (!active.current || failed.current) return;
void onInputConnected(false);
Expand Down Expand Up @@ -173,7 +178,7 @@ function DeviceStreamDocumentView({
else if (message?.type === "input") void onInputConnected(message.connected);
else if (message?.type === "retry") onRetry();
else if (message?.type === "status") {
setStarted(true);
clearBootstrapTimer();
if (message.status === "error") fail(message.detail ?? "Device stream failed.");
else {
setStatus(message.status);
Expand Down
Loading
Loading