1import type { DeviceSummary, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useRef, useState } from "react";
3import {
4 captureDeviceScreenshot,
5 DeviceScreenshotError,
6} from "@t3tools/client-runtime/device/screenshot";
7import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
8import { DeviceControlsRail } from "./DeviceControlsRail";
9import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";
10import { DeviceToolsPanel } from "./DeviceToolsPanel";
11import { useDeviceControls } from "./useDeviceControls";
12
13/** Keyed by environment and device; the screen, quick controls and drawer share the same session. */
14export function DeviceWorkspace(props: {
15 environmentId: EnvironmentId;
16 device: DeviceSummary;
17 hostLabel: string;
18 hostDiagnostics: string | undefined;
19 visible: boolean;
20 onFloat: () => void;
21 onClose: () => void;
22 onPowerOff: () => void;
23}) {
24 const [handle, setHandle] = useState<DeviceStreamHandle | null>(null);
25 const [toolsOpen, setToolsOpen] = useState(false);
26 const [axOverlay, setAxOverlay] = useState(false);
27 const [screenshotPending, setScreenshotPending] = useState(false);
28 const [screenshotError, setScreenshotError] = useState<string | null>(null);
29 const captureRef = useRef<AbortController | null>(null);
30 const downloadRef = useRef<string | null>(null);
31 const access = useDeviceHubAccess(props.environmentId, props.device.hostId);
32 const controls = useDeviceControls({ ...props, access });
33 useEffect(() => {
34 if (!access || !props.visible) captureRef.current?.abort();
35 return () => {
36 captureRef.current?.abort();
37 if (downloadRef.current) URL.revokeObjectURL(downloadRef.current);
38 downloadRef.current = null;
39 };
40 }, [access, props.visible]);
41 const saveScreenshot = () => {
42 if (!access || captureRef.current || !props.visible) return;
43 const controller = new AbortController();
44 captureRef.current = controller;
45 setScreenshotPending(true);
46 setScreenshotError(null);
47 return captureDeviceScreenshot(
48 { access, platform: props.device.platform, deviceId: props.device.id },
49 controller.signal,
50 )
51 .then((image) => {
52 if (controller.signal.aborted) return;
53 if (downloadRef.current) URL.revokeObjectURL(downloadRef.current);
54 const url = URL.createObjectURL(image);
55 downloadRef.current = url;
56 const link = document.createElement("a");
57 link.href = url;
58 link.download = `${props.device.name.replace(/[^a-z0-9-]/gi, "-")}-${Date.now()}.png`;
59 document.body.append(link);
60 link.click();
61 link.remove();
62 })
63 .catch((cause: unknown) => {
64 if (!controller.signal.aborted) {
65 if (cause instanceof DeviceScreenshotError && cause.status === 401)
66 refreshDeviceHubAccess(props.environmentId);
67 setScreenshotError(
68 cause instanceof Error ? cause.message : "Screenshot capture failed. Try again.",
69 );
70 }
71 })
72 .finally(() => {
73 if (captureRef.current === controller) {
74 captureRef.current = null;
75 setScreenshotPending(false);
76 }
77 });
78 };
79 return (
80 <>
81 <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
82 {screenshotError || (controls.error && !toolsOpen) ? (
83 <p role="alert" className="px-3 py-2 text-xs text-destructive">
84 {screenshotError ?? controls.error}
85 </p>
86 ) : null}
87 <div className="min-h-0 flex-1">
88 <DeviceStreamView
89 environmentId={props.environmentId}
90 platform={props.device.platform}
91 deviceName={props.device.name}
92 deviceDescription={`${props.hostLabel} · ${props.device.version}`}
93 deviceId={props.device.id}
94 hostId={props.device.hostId}
95 visible={props.visible}
96 axOverlay={axOverlay}
97 allowPhoneView
98 onHandle={setHandle}
99 renderControls={(view) => (
100 <DeviceControlsRail
101 platform={props.device.platform}
102 handle={handle}
103 view={view}
104 controls={controls}
105 screenshotPending={screenshotPending}
106 onScreenshot={() => void saveScreenshot()}
107 toolsOpen={toolsOpen}
108 onTools={() => setToolsOpen(!toolsOpen)}
109 onFloat={props.onFloat}
110 onClose={props.onClose}
111 onPowerOff={props.onPowerOff}
112 />
113 )}
114 />
115 </div>
116 </div>
117 {toolsOpen ? (
118 <DeviceToolsPanel
119 device={props.device}
120 controls={controls}
121 hostDiagnostics={props.hostDiagnostics}
122 access={access}
123 axOverlay={axOverlay}
124 onAxOverlayChange={setAxOverlay}
125 onClose={() => setToolsOpen(false)}
126 className="absolute inset-y-0 right-0 z-10 w-full max-w-72 border-l shadow-lg @[700px]:static @[700px]:w-72 @[700px]:shrink-0 @[700px]:shadow-none"
127 />
128 ) : null}
129 </>
130 );
131}