1import type { DevicePlatform, PreviewViewportSetting } from "@t3tools/contracts";
2
3import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore";
4import {
5 resolveFittedBrowserViewport,
6 type BrowserViewportResizeDirection,
7} from "~/browser/browserViewportLayout";
8import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
9
10import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
11
12export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;
13export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12;
14// The mini-player shell straddles this webview at 47 and 49: above --z-sheet, under dialogs (50).
15export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48;
16// A fresh player is the largest box at the source aspect ratio that fits here.
17const PREVIEW_MINI_PLAYER_DEFAULT_BOX = { width: 320, height: 320 } as const;
18const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const;
19
20export interface PreviewMiniPlayerFrame extends PreviewMiniPlayerPosition, PreviewMiniPlayerSize {}
21
22/**
23 * The rendered size of what the floating player mirrors: the device viewport
24 * when one is set, otherwise the size the webview had when it was floated
25 * (`fittedSourceContent`), which the hosted webview keeps as its CSS viewport.
26 */
27export function resolvePreviewMiniPlayerSourceSize(
28 viewport: PreviewViewportSetting,
29 fittedSourceContent: BrowserSurfaceContentPresentation | null,
30 zoomFactor: number,
31): PreviewMiniPlayerSize {
32 const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
33 const fitted = resolveFittedBrowserViewport(viewport, fittedSourceContent, normalizedZoomFactor);
34 return {
35 width: fitted.width * normalizedZoomFactor,
36 height: fitted.height * normalizedZoomFactor,
37 };
38}
39
40/**
41 * The device screen as the user sees it, so a rotated phone floats as a
42 * landscape box. Before the stream reports its size the platform's usual phone
43 * shape stands in, matching the stream view's own placeholder aspect; the
44 * nominal width only keeps the source cap above any sensible player width.
45 */
46export function resolveDeviceMiniPlayerSourceSize(
47 platform: DevicePlatform,
48 screen: DeviceScreenSize | null,
49): PreviewMiniPlayerSize {
50 if (!screen) {
51 const width = 1_000;
52 return { width, height: width / (platform === "ios" ? 9 / 19.5 : 9 / 20) };
53 }
54 const landscape =
55 screen.orientation === "landscape_left" || screen.orientation === "landscape_right";
56 const long = Math.max(screen.width, screen.height);
57 const short = Math.min(screen.width, screen.height);
58 return landscape ? { width: long, height: short } : { width: short, height: long };
59}
60
61/**
62 * The Android emulator composites the skin's rounded corners into its
63 * framebuffer as black wedges (measured at ~13% of the short side on a
64 * Pixel 9), so its player clips at a matching phone-like radius; the sliver
65 * lost under the curve is status-bar padding. iOS simulators stream an
66 * edge-to-edge rectangle and keep the frame radius, which matters for iPads
67 * whose real corners are far tighter than a phone's.
68 */
69export function resolveDeviceMiniPlayerCornerRadius(
70 platform: DevicePlatform,
71 player: PreviewMiniPlayerSize,
72): number {
73 if (platform !== "android") return PREVIEW_MINI_PLAYER_CORNER_RADIUS;
74 return Math.max(
75 PREVIEW_MINI_PLAYER_CORNER_RADIUS,
76 Math.round(Math.min(player.width, player.height) * 0.14),
77 );
78}
79
80interface HorizontalSpan {
81 readonly left: number;
82 readonly right: number;
83}
84
85/**
86 * What the player keeps clear of, in container coordinates. Both are docked
87 * to an edge, so each only reserves the columns it covers: the margins beside
88 * the composer and the column left of the details card stay open all the way.
89 */
90export interface PreviewMiniPlayerObstacles {
91 /** The composer stack docked to the bottom edge. */
92 readonly composer: (HorizontalSpan & { readonly height: number }) | null;
93 /** The inline thread-details card docked to the top-right corner. */
94 readonly detailsCard: (HorizontalSpan & { readonly bottom: number }) | null;
95}
96
97export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = {
98 composer: null,
99 detailsCard: null,
100};
101
102const spanOf = (x: number, width: number): HorizontalSpan => ({ left: x, right: x + width });
103
104const spansOverlap = (a: HorizontalSpan, b: HorizontalSpan) => a.left < b.right && a.right > b.left;
105
106/** The lowest row (before the edge gap) open to a player covering these columns. */
107function floorFor(
108 span: HorizontalSpan,
109 container: PreviewMiniPlayerSize,
110 obstacles: PreviewMiniPlayerObstacles,
111): number {
112 const { composer } = obstacles;
113 return composer && spansOverlap(span, composer)
114 ? container.height - Math.max(0, composer.height)
115 : container.height;
116}
117
118/** The highest row (before the edge gap) open to a player covering these columns. */
119function ceilingFor(span: HorizontalSpan, obstacles: PreviewMiniPlayerObstacles): number {
120 const { detailsCard } = obstacles;
121 return detailsCard && spansOverlap(span, detailsCard) ? Math.max(0, detailsCard.bottom) : 0;
122}
123
124/**
125 * The box a stored size is fitted into. A player with a position keeps the
126 * rows its own columns have, so a tall frame parked beside the composer
127 * survives the next layout pass; without one it takes the rows above the
128 * composer, which every column has.
129 */
130const availableArea = (
131 container: PreviewMiniPlayerSize,
132 obstacles: PreviewMiniPlayerObstacles,
133 span: HorizontalSpan | null,
134): PreviewMiniPlayerSize => ({
135 width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
136 height:
137 (span
138 ? floorFor(span, container, obstacles) - ceilingFor(span, obstacles)
139 : container.height - Math.max(0, obstacles.composer?.height ?? 0)) -
140 PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
141});
142
143/**
144 * Width is the player's only free dimension; height always follows the source
145 * aspect ratio so the webview fills the box without letterboxing. The player
146 * may upscale the source, and a tight container wins over the minimum.
147 */
148function fitPreviewMiniPlayerWidth(
149 desiredWidth: number,
150 source: PreviewMiniPlayerSize,
151 max: PreviewMiniPlayerSize,
152): PreviewMiniPlayerSize {
153 const aspectRatio = source.width / source.height;
154 const width = Math.min(
155 Math.max(
156 desiredWidth,
157 PREVIEW_MINI_PLAYER_MIN_SIZE.width,
158 PREVIEW_MINI_PLAYER_MIN_SIZE.height * aspectRatio,
159 ),
160 Math.max(1, max.width),
161 Math.max(1, max.height * aspectRatio),
162 );
163 return { width: Math.round(width), height: Math.round(width / aspectRatio) };
164}
165
166function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number {
167 return Math.min(
168 PREVIEW_MINI_PLAYER_DEFAULT_BOX.width,
169 (PREVIEW_MINI_PLAYER_DEFAULT_BOX.height * source.width) / source.height,
170 );
171}
172
173const clampToContainer = (
174 position: PreviewMiniPlayerPosition,
175 container: PreviewMiniPlayerSize,
176 player: PreviewMiniPlayerSize,
177 minimumX = PREVIEW_MINI_PLAYER_EDGE_GAP,
178): PreviewMiniPlayerPosition => ({
179 x: Math.min(
180 Math.max(position.x, minimumX, PREVIEW_MINI_PLAYER_EDGE_GAP),
181 Math.max(
182 PREVIEW_MINI_PLAYER_EDGE_GAP,
183 container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP,
184 ),
185 ),
186 y: Math.min(
187 Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP),
188 Math.max(
189 PREVIEW_MINI_PLAYER_EDGE_GAP,
190 container.height - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP,
191 ),
192 ),
193});
194
195const overlapsObstacle = (
196 position: PreviewMiniPlayerPosition,
197 player: PreviewMiniPlayerSize,
198 container: PreviewMiniPlayerSize,
199 obstacles: PreviewMiniPlayerObstacles,
200): boolean => {
201 const span = spanOf(position.x, player.width);
202 return (
203 position.y + player.height > floorFor(span, container, obstacles) ||
204 position.y < ceilingFor(span, obstacles)
205 );
206};
207
208/**
209 * Keeps the player inside the container and off the composer and details
210 * card. An overlapping player is moved the shortest distance that leaves it
211 * clear: vertically into the rows its own columns have open, or sideways to
212 * the space beside an obstacle, so a drag slides along the composer into a
213 * margin instead of stopping at its edge. When nothing leaves it clear it
214 * keeps its columns and sits below the card; the composer is where the user
215 * is typing, but a player under the card cannot be reached at all.
216 * minimumX reserves room to the player's left without changing its size.
217 */
218export function clampPreviewMiniPlayerPosition(
219 position: PreviewMiniPlayerPosition,
220 container: PreviewMiniPlayerSize,
221 player: PreviewMiniPlayerSize,
222 obstacles: PreviewMiniPlayerObstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES,
223 minimumX = PREVIEW_MINI_PLAYER_EDGE_GAP,
224): PreviewMiniPlayerPosition {
225 const inside = clampToContainer(position, container, player, minimumX);
226 if (!overlapsObstacle(inside, player, container, obstacles)) return inside;
227 const gap = PREVIEW_MINI_PLAYER_EDGE_GAP;
228 // The rows open to a player at this x, holding y as close to the drag as they allow.
229 const fitRows = (x: number): PreviewMiniPlayerPosition => {
230 const span = spanOf(x, player.width);
231 return {
232 x,
233 y: Math.min(
234 container.height - gap - player.height,
235 Math.max(
236 Math.min(inside.y, floorFor(span, container, obstacles) - gap - player.height),
237 ceilingFor(span, obstacles) + gap,
238 gap,
239 ),
240 ),
241 };
242 };
243 const fallback = fitRows(inside.x);
244 const { composer, detailsCard } = obstacles;
245 const span = spanOf(inside.x, player.width);
246 const beside = [composer, detailsCard]
247 .filter(
248 (obstacle): obstacle is NonNullable<typeof obstacle> =>
249 obstacle !== null && spansOverlap(span, obstacle),
250 )
251 .flatMap((obstacle) => [obstacle.left - gap - player.width, obstacle.right + gap]);
252 let best = fallback;
253 let bestDistance = overlapsObstacle(fallback, player, container, obstacles)
254 ? Number.POSITIVE_INFINITY
255 : Math.abs(fallback.y - inside.y);
256 for (const x of beside) {
257 const candidate = fitRows(x);
258 if (
259 clampToContainer(candidate, container, player, minimumX).x !== x ||
260 overlapsObstacle(candidate, player, container, obstacles)
261 ) {
262 continue;
263 }
264 const distance = Math.abs(x - inside.x) + Math.abs(candidate.y - inside.y);
265 if (distance < bestDistance) {
266 best = candidate;
267 bestDistance = distance;
268 }
269 }
270 return best;
271}
272
273/**
274 * Resolves the on-screen frame from the stored width and position. Clamping
275 * happens here on every layout pass instead of being written back to the
276 * store, so a temporarily narrow container never destroys the user's chosen
277 * width. A player without a position sits in the top-right corner, or tucks
278 * under the details card with right edges aligned when the card owns it.
279 */
280export function resolvePreviewMiniPlayerFrame(input: {
281 readonly width: number | null;
282 readonly position: PreviewMiniPlayerPosition | null;
283 readonly source: PreviewMiniPlayerSize;
284 readonly container: PreviewMiniPlayerSize;
285 readonly obstacles?: PreviewMiniPlayerObstacles;
286}): PreviewMiniPlayerFrame {
287 const {
288 width,
289 position,
290 source,
291 container,
292 obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES,
293 } = input;
294 const size = fitPreviewMiniPlayerWidth(
295 width ?? defaultPreviewMiniPlayerWidth(source),
296 source,
297 availableArea(
298 container,
299 // A stored player keeps its size under the details card — the card's
300 // ceiling is a placement concern, not a sizing one, and the clamp pass
301 // relocates the frame into columns the card leaves open.
302 position && width ? { composer: obstacles.composer, detailsCard: null } : obstacles,
303 position && width ? spanOf(position.x, width) : null,
304 ),
305 );
306 const { detailsCard } = obstacles;
307 const anchored =
308 position ??
309 (detailsCard
310 ? { x: detailsCard.right - size.width, y: detailsCard.bottom + PREVIEW_MINI_PLAYER_EDGE_GAP }
311 : {
312 x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width,
313 y: PREVIEW_MINI_PLAYER_EDGE_GAP,
314 });
315 return { ...clampPreviewMiniPlayerPosition(anchored, container, size, obstacles), ...size };
316}
317
318/**
319 * Resizes from any edge or corner while holding the aspect ratio. The edge
320 * opposite the dragged one stays anchored, so growth stops at the container
321 * on that axis and the pointer keeps tracking the grabbed edge. On a plain edge
322 * drag the perpendicular axis may use the whole container, and the player
323 * shifts as needed to stay inside.
324 */
325export function resizePreviewMiniPlayer(input: {
326 readonly start: PreviewMiniPlayerFrame;
327 readonly direction: BrowserViewportResizeDirection;
328 readonly delta: PreviewMiniPlayerPosition;
329 readonly source: PreviewMiniPlayerSize;
330 readonly container: PreviewMiniPlayerSize;
331 readonly obstacles?: PreviewMiniPlayerObstacles;
332}): PreviewMiniPlayerFrame {
333 const {
334 start,
335 direction,
336 delta,
337 source,
338 container,
339 obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES,
340 } = input;
341 const east = direction.includes("east");
342 const west = direction.includes("west");
343 const north = direction.includes("north");
344 const south = direction.includes("south");
345 const right = start.x + start.width;
346 const bottom = start.y + start.height;
347 // Growth stops where the player's current columns meet an obstacle, and a
348 // plain edge drag lets the free axis use everything those columns have. A
349 // wider player may reach new columns; the clamp below slides it clear.
350 const span = spanOf(start.x, start.width);
351 const floor = floorFor(span, container, obstacles);
352 const ceiling = ceilingFor(span, obstacles);
353 const max = {
354 width: west
355 ? right - PREVIEW_MINI_PLAYER_EDGE_GAP
356 : east
357 ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x
358 : container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
359 height: north
360 ? bottom - ceiling - PREVIEW_MINI_PLAYER_EDGE_GAP
361 : south
362 ? floor - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y
363 : floor - ceiling - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
364 };
365 const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0);
366 const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0);
367 const horizontal = east || west;
368 const vertical = north || south;
369 const aspectRatio = source.width / source.height;
370 // Project corner motion onto the aspect-ratio diagonal. Switching between
371 // dominant axes jumps when one axis grows while the other shrinks.
372 const desired =
373 horizontal && vertical
374 ? (desiredWidth + desiredHeight / aspectRatio) / (1 + 1 / aspectRatio ** 2)
375 : horizontal
376 ? desiredWidth
377 : desiredHeight * aspectRatio;
378 const size = fitPreviewMiniPlayerWidth(desired, source, max);
379 const position = clampPreviewMiniPlayerPosition(
380 { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y },
381 container,
382 size,
383 obstacles,
384 );
385 return { ...position, ...size };
386}