| 1 | import type { DevicePlatform, PreviewViewportSetting } from "@t3tools/contracts"; |
| 2 | |
| 3 | import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore"; |
| 4 | import { |
| 5 | resolveFittedBrowserViewport, |
| 6 | type BrowserViewportResizeDirection, |
| 7 | } from "~/browser/browserViewportLayout"; |
| 8 | import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; |
| 9 | |
| 10 | import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; |
| 11 | |
| 12 | export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; |
| 13 | export 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). |
| 15 | export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; |
| 16 | // A fresh player is the largest box at the source aspect ratio that fits here. |
| 17 | const PREVIEW_MINI_PLAYER_DEFAULT_BOX = { width: 320, height: 320 } as const; |
| 18 | const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; |
| 19 | |
| 20 | export 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 | */ |
| 27 | export 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 | */ |
| 46 | export 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 | */ |
| 69 | export 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 | |
| 80 | interface 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 | */ |
| 90 | export 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 | |
| 97 | export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { |
| 98 | composer: null, |
| 99 | detailsCard: null, |
| 100 | }; |
| 101 | |
| 102 | const spanOf = (x: number, width: number): HorizontalSpan => ({ left: x, right: x + width }); |
| 103 | |
| 104 | const 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. */ |
| 107 | function 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. */ |
| 119 | function 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 | */ |
| 130 | const 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 | */ |
| 148 | function 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 | |
| 166 | function 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 | |
| 173 | const 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 | |
| 195 | const 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 | */ |
| 218 | export 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 | */ |
| 280 | export 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 | */ |
| 325 | export 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 | } |