diff --git a/apps/web/src/components/device/DeviceStreamView.test.tsx b/apps/web/src/components/device/DeviceStreamView.test.tsx index 559050303cf4..7af2d236b785 100644 --- a/apps/web/src/components/device/DeviceStreamView.test.tsx +++ b/apps/web/src/components/device/DeviceStreamView.test.tsx @@ -46,7 +46,14 @@ class Image extends EventTarget { } let renderer: ReactTestRenderer | undefined; let primes = 0; +const storedItems = new Map(); +const storage = { + getItem: (key: string) => storedItems.get(key) ?? null, + setItem: (key: string, value: string) => storedItems.set(key, value), + removeItem: (key: string) => storedItems.delete(key), +}; beforeEach(() => { + storedItems.clear(); primes = 0; viewerMounts = 0; viewerUnmounts = 0; @@ -61,6 +68,11 @@ afterEach(async () => { async function setup(h264 = false) { vi.useFakeTimers(); vi.stubGlobal("window", globalThis); + vi.stubGlobal("localStorage", storage); + const windowEvents = new EventTarget(); + vi.stubGlobal("addEventListener", windowEvents.addEventListener.bind(windowEvents)); + vi.stubGlobal("removeEventListener", windowEvents.removeEventListener.bind(windowEvents)); + vi.stubGlobal("dispatchEvent", windowEvents.dispatchEvent.bind(windowEvents)); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); let videoBody: ReadableStreamDefaultController | undefined; let output: VideoFrameOutputCallback | undefined; @@ -231,3 +243,19 @@ it("keeps the 3D viewer mounted across iOS video recovery and releases it when h expect(viewerUnmounts).toBe(1); expect(vi.getTimerCount()).toBe(0); }); + +it("remembers the flat view when the device view mounts again", async () => { + const first = await setup(true); + await act(async () => first.configure()); + await act(async () => first.frame()); + expect(viewerMounts).toBe(1); + await act(async () => renderer!.root.findByProps({ children: "Flat" }).props.onClick()); + expect(viewerUnmounts).toBe(1); + await act(async () => renderer!.unmount()); + + const second = await setup(true); + await act(async () => second.configure()); + await act(async () => second.frame()); + expect(viewerMounts).toBe(1); + expect(renderer!.root.findByProps({ children: "Flat" }).props["aria-pressed"]).toBe(true); +}); diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index 42bb69cccf56..61a84aedf7f3 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -1,4 +1,5 @@ import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; @@ -23,9 +24,12 @@ import { type DeviceStreamStatus, } from "@t3tools/client-runtime/device/stream"; import { observeResize } from "~/lib/observeResize"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; const AX_POLL_INTERVAL_MS = 2_000; const CONTROLS_RAIL_WIDTH = 56; +const DEVICE_PRESENTATION_STORAGE_KEY = "t3code:device-presentation"; +const DevicePresentation = Schema.Literals(["phone", "flat"]); export interface DeviceViewControls { readonly phone: boolean; @@ -74,7 +78,11 @@ export function DeviceStreamView(props: { }); const model = deviceModel(props.platform, props.deviceName ?? ""); const isDuo = model?.id === "iphone-duo"; - const [presentation, setPresentation] = useState<"phone" | "flat">("phone"); + const [presentation, setPresentation] = useLocalStorage( + DEVICE_PRESENTATION_STORAGE_KEY, + "phone", + DevicePresentation, + ); const [keyboardAttached, setKeyboardAttached] = useState(false); const [phoneUnavailable, setPhoneUnavailable] = useState(false); const onPhoneUnavailable = useCallback(() => setPhoneUnavailable(true), []); diff --git a/docs/user/devices.md b/docs/user/devices.md index a87770853a2d..2827e009ba56 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -44,6 +44,9 @@ interactive. On iPhone Duo, use the fold and stance controls to change its physical pose, or pinch over the device to adjust the hinge. Turning the model to the other screen switches the live display and touch input to that screen. **Restore 3D view** returns the device to a screen-facing position. +Choose **Flat view** for the plain screen. Your choice between the two views is +remembered on this browser or desktop app, so devices open in the view you last +picked. On supported Android foldables, use **Fold device** and **Unfold device** beside the screen to change its posture in either view.