1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { DevicePlatform, ScopedThreadRef } from "@t3tools/contracts";
3import { create } from "zustand";
4
5export interface PreviewMiniPlayerPosition {
6 readonly x: number;
7 readonly y: number;
8}
9
10export interface PreviewMiniPlayerSize {
11 readonly width: number;
12 readonly height: number;
13}
14
15/** What the floating player mirrors: a browser tab or a device stream. */
16export type PreviewMiniPlayerSource =
17 | { readonly kind: "browser"; readonly tabId: string }
18 | {
19 readonly kind: "device";
20 readonly hostId: string;
21 readonly deviceId: string;
22 readonly platform: DevicePlatform;
23 readonly name: string;
24 };
25
26export interface PreviewMiniPlayerState {
27 readonly source: PreviewMiniPlayerSource;
28 readonly position: PreviewMiniPlayerPosition | null;
29 /** Height always follows the mirrored source's aspect ratio. */
30 readonly width: number | null;
31 readonly lastInteraction: "drag" | "resize";
32}
33
34interface PreviewMiniPlayerStoreState {
35 readonly byThreadKey: Record<string, PreviewMiniPlayerState>;
36 readonly open: (ref: ScopedThreadRef, source: PreviewMiniPlayerSource) => void;
37 readonly close: (ref: ScopedThreadRef) => void;
38 /** `sourceKey` guards against a drag that outlives the source it started on. */
39 readonly move: (
40 ref: ScopedThreadRef,
41 sourceKey: string,
42 position: PreviewMiniPlayerPosition,
43 ) => void;
44 readonly resize: (
45 ref: ScopedThreadRef,
46 sourceKey: string,
47 width: number,
48 position?: PreviewMiniPlayerPosition,
49 ) => void;
50 readonly removeThread: (ref: ScopedThreadRef) => void;
51}
52
53export function previewMiniPlayerSourceKey(source: PreviewMiniPlayerSource): string {
54 return source.kind === "browser"
55 ? `browser:${source.tabId}`
56 : `device:${encodeURIComponent(source.hostId)}:${encodeURIComponent(source.deviceId)}`;
57}
58
59export const browserMiniPlayerSource = (tabId: string): PreviewMiniPlayerSource => ({
60 kind: "browser",
61 tabId,
62});
63
64export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((set) => ({
65 byThreadKey: {},
66 open: (ref, source) =>
67 set((state) => {
68 const threadKey = scopedThreadKey(ref);
69 const current = state.byThreadKey[threadKey];
70 if (
71 current &&
72 previewMiniPlayerSourceKey(current.source) === previewMiniPlayerSourceKey(source)
73 ) {
74 return state;
75 }
76 return {
77 byThreadKey: {
78 ...state.byThreadKey,
79 [threadKey]: {
80 source,
81 position: current?.position ?? null,
82 width: current?.width ?? null,
83 lastInteraction: current?.lastInteraction ?? "drag",
84 },
85 },
86 };
87 }),
88 close: (ref) =>
89 set((state) => {
90 const threadKey = scopedThreadKey(ref);
91 if (!(threadKey in state.byThreadKey)) return state;
92 const { [threadKey]: _closed, ...byThreadKey } = state.byThreadKey;
93 return { byThreadKey };
94 }),
95 move: (ref, sourceKey, position) =>
96 set((state) => {
97 const threadKey = scopedThreadKey(ref);
98 const current = state.byThreadKey[threadKey];
99 if (!current || previewMiniPlayerSourceKey(current.source) !== sourceKey) return state;
100 if (
101 current.position?.x === position.x &&
102 current.position.y === position.y &&
103 current.lastInteraction === "drag"
104 )
105 return state;
106 return {
107 byThreadKey: {
108 ...state.byThreadKey,
109 [threadKey]: { ...current, position, lastInteraction: "drag" },
110 },
111 };
112 }),
113 resize: (ref, sourceKey, width, position) =>
114 set((state) => {
115 const threadKey = scopedThreadKey(ref);
116 const current = state.byThreadKey[threadKey];
117 if (
118 !current ||
119 previewMiniPlayerSourceKey(current.source) !== sourceKey ||
120 (current.width === width &&
121 current.lastInteraction === "resize" &&
122 (!position || (current.position?.x === position.x && current.position.y === position.y)))
123 ) {
124 return state;
125 }
126 return {
127 byThreadKey: {
128 ...state.byThreadKey,
129 [threadKey]: {
130 ...current,
131 width,
132 position: position ?? current.position,
133 lastInteraction: "resize",
134 },
135 },
136 };
137 }),
138 removeThread: (ref) =>
139 set((state) => {
140 const threadKey = scopedThreadKey(ref);
141 if (!(threadKey in state.byThreadKey)) return state;
142 const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey;
143 return { byThreadKey };
144 }),
145}));
146
147export function selectThreadPreviewMiniPlayer(
148 byThreadKey: Record<string, PreviewMiniPlayerState>,
149 ref: ScopedThreadRef | null | undefined,
150): PreviewMiniPlayerState | null {
151 if (!ref) return null;
152 return byThreadKey[scopedThreadKey(ref)] ?? null;
153}
154
155/** The floating browser tab, or null when nothing floats or a device does. */
156export function selectThreadPreviewMiniPlayerTabId(
157 byThreadKey: Record<string, PreviewMiniPlayerState>,
158 ref: ScopedThreadRef | null | undefined,
159): string | null {
160 const source = selectThreadPreviewMiniPlayer(byThreadKey, ref)?.source;
161 return source?.kind === "browser" ? source.tabId : null;
162}