Skip to content
Closed
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
28 changes: 28 additions & 0 deletions apps/web/src/components/device/DeviceStreamView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,14 @@ class Image extends EventTarget {
}
let renderer: ReactTestRenderer | undefined;
let primes = 0;
const storedItems = new Map<string, string>();
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;
Expand All @@ -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<Uint8Array> | undefined;
let output: VideoFrameOutputCallback | undefined;
Expand Down Expand Up @@ -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);
});
10 changes: 9 additions & 1 deletion apps/web/src/components/device/DeviceStreamView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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), []);
Expand Down
3 changes: 3 additions & 0 deletions docs/user/devices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading