import type { PreviewViewportSetting } from "@t3tools/contracts"; import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore";import { resolveFittedBrowserViewport, type BrowserViewportResizeDirection,} from "~/browser/browserViewportLayout";import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;// The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50.export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48;// A fresh player is the largest box at the source aspect ratio that fits here.const PREVIEW_MINI_PLAYER_DEFAULT_BOX = { width: 320, height: 320 } as const;const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; export interface PreviewMiniPlayerFrame extends PreviewMiniPlayerPosition, PreviewMiniPlayerSize {} /** * The rendered size of what the floating player mirrors: the device viewport * when one is set, otherwise the size the webview had when it was floated * (`fittedSourceContent`), which the hosted webview keeps as its CSS viewport. */export function resolvePreviewMiniPlayerSourceSize( viewport: PreviewViewportSetting, fittedSourceContent: BrowserSurfaceContentPresentation | null, zoomFactor: number,): PreviewMiniPlayerSize { const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; const fitted = resolveFittedBrowserViewport(viewport, fittedSourceContent, normalizedZoomFactor); return { width: fitted.width * normalizedZoomFactor, height: fitted.height * normalizedZoomFactor, };} const availableArea = ( container: PreviewMiniPlayerSize, bottomInset: number,): PreviewMiniPlayerSize => ({ width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, height: container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,}); /** * Width is the player's only free dimension; height always follows the source * aspect ratio so the webview fills the box without letterboxing. The player * never grows past the source's own size (the guest keeps its CSS viewport, so * going bigger would only upscale), and a tight container wins over the minimum. */function fitPreviewMiniPlayerWidth( desiredWidth: number, source: PreviewMiniPlayerSize, max: PreviewMiniPlayerSize,): PreviewMiniPlayerSize { const aspectRatio = source.width / source.height; const width = Math.min( Math.max( desiredWidth, PREVIEW_MINI_PLAYER_MIN_SIZE.width, PREVIEW_MINI_PLAYER_MIN_SIZE.height * aspectRatio, ), source.width, Math.max(1, max.width), Math.max(1, max.height * aspectRatio), ); return { width: Math.round(width), height: Math.round(width / aspectRatio) };} function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number { return Math.min( PREVIEW_MINI_PLAYER_DEFAULT_BOX.width, (PREVIEW_MINI_PLAYER_DEFAULT_BOX.height * source.width) / source.height, );} export function clampPreviewMiniPlayerPosition( position: PreviewMiniPlayerPosition, container: PreviewMiniPlayerSize, player: PreviewMiniPlayerSize, bottomInset = 0,): PreviewMiniPlayerPosition { const reservedBottomSpace = Math.max(0, bottomInset); const maxX = Math.max( PREVIEW_MINI_PLAYER_EDGE_GAP, container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP, ); const maxY = Math.max( PREVIEW_MINI_PLAYER_EDGE_GAP, container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP, ); return { x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX), y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY), };} /** * Resolves the on-screen frame from the stored width and position. Clamping * happens here on every layout pass instead of being written back to the * store, so a temporarily narrow container never destroys the user's chosen * width. A player without a position sits in the top-right corner. */export function resolvePreviewMiniPlayerFrame(input: { readonly width: number | null; readonly position: PreviewMiniPlayerPosition | null; readonly source: PreviewMiniPlayerSize; readonly container: PreviewMiniPlayerSize; readonly bottomInset?: number;}): PreviewMiniPlayerFrame { const { width, position, source, container, bottomInset = 0 } = input; const size = fitPreviewMiniPlayerWidth( width ?? defaultPreviewMiniPlayerWidth(source), source, availableArea(container, bottomInset), ); const anchored = position ?? { x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width, y: PREVIEW_MINI_PLAYER_EDGE_GAP, }; return { ...clampPreviewMiniPlayerPosition(anchored, container, size, bottomInset), ...size };} /** * Resizes from any edge or corner while holding the aspect ratio. The edge * opposite the dragged one stays anchored, so growth stops at the container * on that axis and the pointer keeps tracking the grabbed edge. On a plain edge * drag the perpendicular axis may use the whole container, and the player * shifts as needed to stay inside. */export function resizePreviewMiniPlayer(input: { readonly start: PreviewMiniPlayerFrame; readonly direction: BrowserViewportResizeDirection; readonly delta: PreviewMiniPlayerPosition; readonly source: PreviewMiniPlayerSize; readonly container: PreviewMiniPlayerSize; readonly bottomInset?: number;}): PreviewMiniPlayerFrame { const { start, direction, delta, source, container, bottomInset = 0 } = input; const east = direction.includes("east"); const west = direction.includes("west"); const north = direction.includes("north"); const south = direction.includes("south"); const available = availableArea(container, bottomInset); const right = start.x + start.width; const bottom = start.y + start.height; const max = { width: west ? right - PREVIEW_MINI_PLAYER_EDGE_GAP : east ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x : available.width, height: north ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP : south ? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y : available.height, }; const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0); const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0); const horizontal = east || west; const vertical = north || south; const widthLeads = horizontal && !vertical ? true : vertical && !horizontal ? false : Math.abs(desiredWidth - start.width) / start.width >= Math.abs(desiredHeight - start.height) / start.height; const size = fitPreviewMiniPlayerWidth( widthLeads ? desiredWidth : (desiredHeight * source.width) / source.height, source, max, ); const position = clampPreviewMiniPlayerPosition( { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y }, container, size, bottomInset, ); return { ...position, ...size };}
import type { PreviewViewportSetting } from "@t3tools/contracts";
import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore";
import {
resolveFittedBrowserViewport,
type BrowserViewportResizeDirection,
} from "~/browser/browserViewportLayout";
import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;
// The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50.
export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48;
// A fresh player is the largest box at the source aspect ratio that fits here.
const PREVIEW_MINI_PLAYER_DEFAULT_BOX = { width: 320, height: 320 } as const;
const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const;
export interface PreviewMiniPlayerFrame extends PreviewMiniPlayerPosition, PreviewMiniPlayerSize {}
/**
* The rendered size of what the floating player mirrors: the device viewport
* when one is set, otherwise the size the webview had when it was floated
* (`fittedSourceContent`), which the hosted webview keeps as its CSS viewport.
*/
export function resolvePreviewMiniPlayerSourceSize(
viewport: PreviewViewportSetting,
fittedSourceContent: BrowserSurfaceContentPresentation | null,
zoomFactor: number,
): PreviewMiniPlayerSize {
const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
const fitted = resolveFittedBrowserViewport(viewport, fittedSourceContent, normalizedZoomFactor);
return {
width: fitted.width * normalizedZoomFactor,
height: fitted.height * normalizedZoomFactor,
};
}
const availableArea = (
container: PreviewMiniPlayerSize,
bottomInset: number,
): PreviewMiniPlayerSize => ({
width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
height: container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
});
/**
* Width is the player's only free dimension; height always follows the source
* aspect ratio so the webview fills the box without letterboxing. The player
* never grows past the source's own size (the guest keeps its CSS viewport, so
* going bigger would only upscale), and a tight container wins over the minimum.
*/
function fitPreviewMiniPlayerWidth(
desiredWidth: number,
source: PreviewMiniPlayerSize,
max: PreviewMiniPlayerSize,
): PreviewMiniPlayerSize {
const aspectRatio = source.width / source.height;
const width = Math.min(
Math.max(
desiredWidth,
PREVIEW_MINI_PLAYER_MIN_SIZE.width,
PREVIEW_MINI_PLAYER_MIN_SIZE.height * aspectRatio,
),
source.width,
Math.max(1, max.width),
Math.max(1, max.height * aspectRatio),
);
return { width: Math.round(width), height: Math.round(width / aspectRatio) };
}
function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number {
return Math.min(
PREVIEW_MINI_PLAYER_DEFAULT_BOX.width,
(PREVIEW_MINI_PLAYER_DEFAULT_BOX.height * source.width) / source.height,
);
}
export function clampPreviewMiniPlayerPosition(
position: PreviewMiniPlayerPosition,
container: PreviewMiniPlayerSize,
player: PreviewMiniPlayerSize,
bottomInset = 0,
): PreviewMiniPlayerPosition {
const reservedBottomSpace = Math.max(0, bottomInset);
const maxX = Math.max(
PREVIEW_MINI_PLAYER_EDGE_GAP,
container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP,
);
const maxY = Math.max(
PREVIEW_MINI_PLAYER_EDGE_GAP,
container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP,
);
return {
x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX),
y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY),
};
}
/**
* Resolves the on-screen frame from the stored width and position. Clamping
* happens here on every layout pass instead of being written back to the
* store, so a temporarily narrow container never destroys the user's chosen
* width. A player without a position sits in the top-right corner.
*/
export function resolvePreviewMiniPlayerFrame(input: {
readonly width: number | null;
readonly position: PreviewMiniPlayerPosition | null;
readonly source: PreviewMiniPlayerSize;
readonly container: PreviewMiniPlayerSize;
readonly bottomInset?: number;
}): PreviewMiniPlayerFrame {
const { width, position, source, container, bottomInset = 0 } = input;
const size = fitPreviewMiniPlayerWidth(
width ?? defaultPreviewMiniPlayerWidth(source),
source,
availableArea(container, bottomInset),
);
const anchored = position ?? {
x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width,
y: PREVIEW_MINI_PLAYER_EDGE_GAP,
};
return { ...clampPreviewMiniPlayerPosition(anchored, container, size, bottomInset), ...size };
}
/**
* Resizes from any edge or corner while holding the aspect ratio. The edge
* opposite the dragged one stays anchored, so growth stops at the container
* on that axis and the pointer keeps tracking the grabbed edge. On a plain edge
* drag the perpendicular axis may use the whole container, and the player
* shifts as needed to stay inside.
*/
export function resizePreviewMiniPlayer(input: {
readonly start: PreviewMiniPlayerFrame;
readonly direction: BrowserViewportResizeDirection;
readonly delta: PreviewMiniPlayerPosition;
readonly source: PreviewMiniPlayerSize;
readonly container: PreviewMiniPlayerSize;
readonly bottomInset?: number;
}): PreviewMiniPlayerFrame {
const { start, direction, delta, source, container, bottomInset = 0 } = input;
const east = direction.includes("east");
const west = direction.includes("west");
const north = direction.includes("north");
const south = direction.includes("south");
const available = availableArea(container, bottomInset);
const right = start.x + start.width;
const bottom = start.y + start.height;
const max = {
width: west
? right - PREVIEW_MINI_PLAYER_EDGE_GAP
: east
? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x
: available.width,
height: north
? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP
: south
? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y
: available.height,
};
const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0);
const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0);
const horizontal = east || west;
const vertical = north || south;
const widthLeads =
horizontal && !vertical
? true
: vertical && !horizontal
? false
: Math.abs(desiredWidth - start.width) / start.width >=
Math.abs(desiredHeight - start.height) / start.height;
const size = fitPreviewMiniPlayerWidth(
widthLeads ? desiredWidth : (desiredHeight * source.width) / source.height,
source,
max,
);
const position = clampPreviewMiniPlayerPosition(
{ x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y },
container,
size,
bottomInset,
);
return { ...position, ...size };
}