1import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";
6import { AsyncResult, Atom } from "effect/reactivity";
7
8import {
9 appendDeviceEventLog,
10 type DeviceAxElement,
11 type DeviceEventLogEntry,
12 type DeviceForegroundInfo,
13 type DeviceHubTarget,
14 fetchDeviceAxTree,
15 subscribeDeviceEventLog,
16 subscribeDeviceForeground,
17} from "./deviceHubApi";
18import { AndroidFoldError, readAndroidFold, type AndroidFoldState } from "./deviceFold";
19
20const isAndroidFoldError = Schema.is(AndroidFoldError);
21
22const AX_POLL_INTERVAL_MS = 2_000;
23const FOLD_RETRY_MS = 3_000;
24
25/** `undefined` until the hub reports an app; the device detail's app fills in until then. */
26export const deviceForegroundAtom = Atom.family((target: DeviceHubTarget) =>
27 Atom.make<DeviceForegroundInfo | null | undefined>((get) => {
28 get.addFinalizer(subscribeDeviceForeground(target, (app) => get.setSelf(app)));
29 return undefined;
30 }).pipe(Atom.withLabel(`device-foreground:${target.deviceId}`)),
31);
32
33export const deviceEventLogAtom = Atom.family((target: DeviceHubTarget) =>
34 Atom.make<ReadonlyArray<DeviceEventLogEntry>>((get) => {
35 let entries: ReadonlyArray<DeviceEventLogEntry> = [];
36 get.addFinalizer(
37 subscribeDeviceEventLog(target, (incoming, reset) => {
38 entries = appendDeviceEventLog(entries, incoming, reset);
39 get.setSelf(entries);
40 }),
41 );
42 return entries;
43 }).pipe(Atom.withLabel(`device-event-log:${target.deviceId}`)),
44);
45
46class DeviceAxTreeFetchError extends Schema.TaggedError<DeviceAxTreeFetchError>()(
47 "DeviceAxTreeFetchError",
48 { cause: Schema.Defect() },
49) {
50 override get message(): string {
51 return "Could not read the device accessibility tree.";
52 }
53}
54
55// A failed poll keeps the last frames on screen; the next poll replaces them.
56export const deviceAxElementsAtom = Atom.family((target: DeviceHubTarget) =>
57 Atom.make(
58 Stream.fromEffectSchedule(
59 Effect.tryPromise({
60 try: (signal) => fetchDeviceAxTree(target, signal),
61 catch: (cause) => new DeviceAxTreeFetchError({ cause }),
62 }).pipe(
63 Effect.map((tree): ReadonlyArray<DeviceAxElement> | null => tree.elements),
64 Effect.orElseSucceed(() => null),
65 ),
66 Schedule.spaced(AX_POLL_INTERVAL_MS),
67 ).pipe(Stream.filter((elements) => elements !== null)),
68 ).pipe(Atom.withLabel(`device-ax-elements:${target.deviceId}`)),
69);
70
71export interface AndroidFoldView {
72 readonly fold: AndroidFoldState | null;
73 /** Hinge angle the 3D phone shows, including an optimistic one while a change is in flight. */
74 readonly angle: number | null;
75}
76
77export const foldAngle = (fold: AndroidFoldState) =>
78 fold.hingeAngle ?? (fold.posture === "closed" ? 0 : 180);
79
80/**
81 * The fold controls and the 3D phone share one view of a device's fold. Keyed by
82 * host and device, not by hub access, so a renewed ticket keeps the known posture.
83 */
84export const androidFoldViewAtom = Atom.family((deviceKey: string) =>
85 Atom.make<AndroidFoldView>({ fold: null, angle: null }).pipe(
86 Atom.withLabel(`android-fold-view:${deviceKey}`),
87 ),
88);
89
90/**
91 * Reads the fold while mounted and records it in the device's fold view. The
92 * screen size is part of the key because folding resizes the screen, so a new
93 * size means the posture may have changed. An expired ticket stops retrying:
94 * renewed access is a new key and reads again.
95 */
96export const androidFoldReadAtom = Atom.family(
97 (input: {
98 readonly deviceKey: string;
99 readonly access: DeviceHubAccess;
100 readonly deviceId: string;
101 readonly screenWidth: number;
102 readonly screenHeight: number;
103 }) =>
104 Atom.make((get) =>
105 Effect.tryPromise({
106 try: (signal) => readAndroidFold(input.access, input.deviceId, signal),
107 catch: (cause) => ({ expired: isAndroidFoldError(cause) && cause.status === 401 }),
108 }).pipe(
109 Effect.retry({
110 while: (failure) => !failure.expired,
111 schedule: Schedule.spaced(FOLD_RETRY_MS),
112 }),
113 Effect.tap((fold) =>
114 Effect.sync(() =>
115 get.set(androidFoldViewAtom(input.deviceKey), {
116 fold,
117 angle: fold.supported ? foldAngle(fold) : null,
118 }),
119 ),
120 ),
121 Effect.asVoid,
122 ),
123 ).pipe(Atom.withLabel(`android-fold-read:${input.deviceKey}`)),
124);
125
126export const NOOP_ATOM = Atom.make(AsyncResult.initial<void, never>()).pipe(
127 Atom.withLabel("device:noop"),
128);
129
130export const NO_FOREGROUND_ATOM = Atom.make<DeviceForegroundInfo | null | undefined>(
131 undefined,
132).pipe(Atom.withLabel("device-foreground:none"));