| 1 | import type { DevicePlatform } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | Camera, |
| 4 | ChevronLeft, |
| 5 | Home, |
| 6 | Keyboard, |
| 7 | Box, |
| 8 | Maximize, |
| 9 | MoreHorizontal, |
| 10 | PictureInPicture2, |
| 11 | Power, |
| 12 | RotateCcw, |
| 13 | SlidersHorizontal, |
| 14 | Smartphone, |
| 15 | Square, |
| 16 | Type, |
| 17 | X, |
| 18 | } from "lucide-react"; |
| 19 | import { Moon, Sun } from "lucide"; |
| 20 | import type { ReactNode } from "react"; |
| 21 | import { Button } from "~/components/ui/button"; |
| 22 | import { MorphIcon } from "~/components/MorphIcon"; |
| 23 | import { |
| 24 | Menu, |
| 25 | MenuItem, |
| 26 | MenuPopup, |
| 27 | MenuSeparator, |
| 28 | MenuTrigger, |
| 29 | MenuRadioGroup, |
| 30 | MenuRadioItem, |
| 31 | MenuRadioItemIndicator, |
| 32 | } from "~/components/ui/menu"; |
| 33 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 34 | import type { DeviceStreamHandle, DeviceViewControls } from "./DeviceStreamView"; |
| 35 | import type { DeviceControls } from "./useDeviceControls"; |
| 36 | |
| 37 | /** Stable floating controls for both presentations, regardless of panel width. */ |
| 38 | export function DeviceControlsRail(props: { |
| 39 | platform: DevicePlatform; |
| 40 | handle: DeviceStreamHandle | null; |
| 41 | view: DeviceViewControls; |
| 42 | controls: DeviceControls; |
| 43 | screenshotPending: boolean; |
| 44 | onScreenshot: () => void; |
| 45 | toolsOpen: boolean; |
| 46 | onTools: () => void; |
| 47 | onFloat: () => void; |
| 48 | onClose: () => void; |
| 49 | onPowerOff: () => void; |
| 50 | }) { |
| 51 | const { view, handle, controls } = props; |
| 52 | const popupSide = "left"; |
| 53 | const settings = controls.detail?.settings; |
| 54 | const inputDisabled = !handle?.inputConnected; |
| 55 | const nextAppearance = settings?.appearance === "dark" ? "light" : "dark"; |
| 56 | return ( |
| 57 | <aside |
| 58 | aria-label="Device controls" |
| 59 | data-layout="rail" |
| 60 | className="pointer-events-none absolute inset-y-0 right-0 z-10 flex w-14 flex-col items-center gap-2 overflow-y-auto [justify-content:safe_center] py-3 pr-2 [scrollbar-width:none]" |
| 61 | > |
| 62 | <div className="pointer-events-auto flex shrink-0 flex-col items-center gap-1 overflow-y-auto rounded-full border border-border/50 bg-background/80 p-2 shadow-sm [scrollbar-width:none]"> |
| 63 | <RailButton |
| 64 | tooltipSide={popupSide} |
| 65 | label="Home" |
| 66 | disabled={inputDisabled} |
| 67 | onClick={() => handle?.pressButton("home")} |
| 68 | > |
| 69 | <Home /> |
| 70 | </RailButton> |
| 71 | {props.platform === "android" ? ( |
| 72 | <> |
| 73 | <RailButton |
| 74 | tooltipSide={popupSide} |
| 75 | label="Back" |
| 76 | disabled={inputDisabled} |
| 77 | onClick={() => handle?.pressButton("back")} |
| 78 | > |
| 79 | <ChevronLeft /> |
| 80 | </RailButton> |
| 81 | <RailButton |
| 82 | tooltipSide={popupSide} |
| 83 | label="Recents" |
| 84 | disabled={inputDisabled} |
| 85 | onClick={() => handle?.pressButton("recents")} |
| 86 | > |
| 87 | <Square /> |
| 88 | </RailButton> |
| 89 | <Menu> |
| 90 | <MenuTrigger |
| 91 | render={ |
| 92 | <Button |
| 93 | size="icon-sm" |
| 94 | variant="ghost" |
| 95 | aria-label="Rotate device" |
| 96 | disabled={controls.disabled} |
| 97 | /> |
| 98 | } |
| 99 | > |
| 100 | <RotateCcw /> |
| 101 | </MenuTrigger> |
| 102 | <MenuPopup side={popupSide}> |
| 103 | <MenuItem |
| 104 | onClick={() => void controls.act({ type: "setOrientation", value: "portrait" })} |
| 105 | > |
| 106 | Portrait |
| 107 | </MenuItem> |
| 108 | <MenuItem |
| 109 | onClick={() => |
| 110 | void controls.act({ type: "setOrientation", value: "landscape_left" }) |
| 111 | } |
| 112 | > |
| 113 | Landscape |
| 114 | </MenuItem> |
| 115 | </MenuPopup> |
| 116 | </Menu> |
| 117 | </> |
| 118 | ) : ( |
| 119 | <RailButton |
| 120 | tooltipSide={popupSide} |
| 121 | label="Rotate device" |
| 122 | disabled={inputDisabled || !!view.keyboard?.attached} |
| 123 | onClick={() => handle?.rotate()} |
| 124 | > |
| 125 | <RotateCcw /> |
| 126 | </RailButton> |
| 127 | )} |
| 128 | <RailDivider /> |
| 129 | <RailButton |
| 130 | tooltipSide={popupSide} |
| 131 | label={`Switch device to ${nextAppearance} mode`} |
| 132 | disabled={controls.disabled || !settings?.appearance} |
| 133 | onClick={() => void controls.act({ type: "setAppearance", value: nextAppearance })} |
| 134 | > |
| 135 | <MorphIcon icon={settings?.appearance === "dark" ? Sun : Moon} /> |
| 136 | </RailButton> |
| 137 | <Menu> |
| 138 | <MenuTrigger |
| 139 | render={ |
| 140 | <Button |
| 141 | size="icon-sm" |
| 142 | variant="ghost" |
| 143 | aria-label="Device text size" |
| 144 | title="Device text size" |
| 145 | disabled={controls.disabled || !settings?.textSize} |
| 146 | /> |
| 147 | } |
| 148 | > |
| 149 | <Type /> |
| 150 | </MenuTrigger> |
| 151 | <MenuPopup side={popupSide} className="min-w-40"> |
| 152 | <MenuRadioGroup |
| 153 | value={settings?.textSize ?? ""} |
| 154 | onValueChange={(value) => { |
| 155 | if ( |
| 156 | value === "small" || |
| 157 | value === "default" || |
| 158 | value === "large" || |
| 159 | value === "extra-large" |
| 160 | ) |
| 161 | void controls.act({ type: "setTextSize", value }); |
| 162 | }} |
| 163 | > |
| 164 | {( |
| 165 | [ |
| 166 | ["small", "Small"], |
| 167 | ["default", "Default"], |
| 168 | ["large", "Large"], |
| 169 | ["extra-large", "Extra large"], |
| 170 | ] as const |
| 171 | ).map(([value, label]) => ( |
| 172 | <MenuRadioItem key={value} value={value}> |
| 173 | <span className="flex items-center gap-2"> |
| 174 | <span className="flex-1">{label}</span> |
| 175 | <MenuRadioItemIndicator /> |
| 176 | </span> |
| 177 | </MenuRadioItem> |
| 178 | ))} |
| 179 | </MenuRadioGroup> |
| 180 | </MenuPopup> |
| 181 | </Menu> |
| 182 | <RailButton |
| 183 | tooltipSide={popupSide} |
| 184 | label="Device tools" |
| 185 | pressed={props.toolsOpen} |
| 186 | onClick={props.onTools} |
| 187 | > |
| 188 | <SlidersHorizontal /> |
| 189 | </RailButton> |
| 190 | <RailButton |
| 191 | tooltipSide={popupSide} |
| 192 | label={props.screenshotPending ? "Capturing screenshot" : "Save screenshot"} |
| 193 | disabled={!view.streaming || props.screenshotPending} |
| 194 | onClick={props.onScreenshot} |
| 195 | > |
| 196 | <Camera /> |
| 197 | </RailButton> |
| 198 | <Menu> |
| 199 | <MenuTrigger |
| 200 | render={ |
| 201 | <Button |
| 202 | size="icon-sm" |
| 203 | variant="ghost" |
| 204 | aria-label="More device actions" |
| 205 | title="More device actions" |
| 206 | /> |
| 207 | } |
| 208 | > |
| 209 | <MoreHorizontal /> |
| 210 | </MenuTrigger> |
| 211 | <MenuPopup side={popupSide} align="end"> |
| 212 | <MenuItem onClick={props.onFloat}> |
| 213 | <PictureInPicture2 /> |
| 214 | Float device over chat |
| 215 | </MenuItem> |
| 216 | <MenuItem onClick={props.onClose}> |
| 217 | <X /> |
| 218 | Close device panel |
| 219 | </MenuItem> |
| 220 | <MenuSeparator /> |
| 221 | <MenuItem variant="destructive" onClick={props.onPowerOff}> |
| 222 | <Power /> |
| 223 | Power off device |
| 224 | </MenuItem> |
| 225 | </MenuPopup> |
| 226 | </Menu> |
| 227 | <RailDivider /> |
| 228 | <RailButton |
| 229 | tooltipSide={popupSide} |
| 230 | label="3D view" |
| 231 | pressed={view.phone} |
| 232 | disabled={!view.streaming || !!view.phoneUnavailableReason} |
| 233 | description={view.phoneUnavailableReason ?? undefined} |
| 234 | onClick={view.showPhone} |
| 235 | > |
| 236 | <Box /> |
| 237 | </RailButton> |
| 238 | <RailButton |
| 239 | tooltipSide={popupSide} |
| 240 | label="Flat view" |
| 241 | pressed={!view.phone} |
| 242 | disabled={!view.streaming} |
| 243 | onClick={view.showFlat} |
| 244 | > |
| 245 | <Smartphone /> |
| 246 | </RailButton> |
| 247 | {view.keyboard ? ( |
| 248 | <RailButton |
| 249 | tooltipSide={popupSide} |
| 250 | label={view.keyboard.attached ? "Detach Magic Keyboard" : "Attach Magic Keyboard"} |
| 251 | pressed={view.keyboard.attached} |
| 252 | onClick={view.keyboard.toggle} |
| 253 | > |
| 254 | <Keyboard /> |
| 255 | </RailButton> |
| 256 | ) : null} |
| 257 | {view.phone ? ( |
| 258 | <RailButton tooltipSide={popupSide} label="Restore 3D view" onClick={view.resetView}> |
| 259 | <Maximize /> |
| 260 | </RailButton> |
| 261 | ) : null} |
| 262 | </div> |
| 263 | {view.foldingControls} |
| 264 | </aside> |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | function RailDivider() { |
| 269 | return <div aria-hidden className="my-1 h-px w-5 shrink-0 bg-border/70" />; |
| 270 | } |
| 271 | |
| 272 | function RailButton(props: { |
| 273 | tooltipSide: "left" | "bottom"; |
| 274 | label: string; |
| 275 | description?: string | undefined; |
| 276 | disabled?: boolean; |
| 277 | pressed?: boolean; |
| 278 | onClick: () => void; |
| 279 | children: ReactNode; |
| 280 | }) { |
| 281 | const button = ( |
| 282 | <Button |
| 283 | size="icon-sm" |
| 284 | variant={props.pressed ? "secondary" : "ghost"} |
| 285 | aria-label={props.label} |
| 286 | aria-pressed={props.pressed} |
| 287 | disabled={props.disabled} |
| 288 | onClick={props.onClick} |
| 289 | > |
| 290 | {props.children} |
| 291 | </Button> |
| 292 | ); |
| 293 | return ( |
| 294 | <Tooltip> |
| 295 | {props.disabled && props.description ? ( |
| 296 | <TooltipTrigger |
| 297 | render={ |
| 298 | <span className="inline-flex" tabIndex={0} role="group" aria-label={props.label} /> |
| 299 | } |
| 300 | > |
| 301 | {button} |
| 302 | </TooltipTrigger> |
| 303 | ) : ( |
| 304 | <TooltipTrigger render={button} /> |
| 305 | )} |
| 306 | <TooltipPopup side={props.tooltipSide}>{props.description ?? props.label}</TooltipPopup> |
| 307 | </Tooltip> |
| 308 | ); |
| 309 | } |