mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
278 lines
9.5 KiB
TypeScript
278 lines
9.5 KiB
TypeScript
import { spacing } from '../../theme/spacing.ts';
|
|
import { WIDE_MIN_WIDTH, isWideWindow } from '../../theme/adaptive.ts';
|
|
|
|
const MAX_CONTENT_WIDTH = 408;
|
|
const CONTENT_SIDE_PADDING = spacing.lg;
|
|
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
|
|
const MEDIA_AREA_MIN = 220;
|
|
const TABLET_MAX_CONTENT_WIDTH = 520;
|
|
const TABLET_ART_SIZE_MAX = 440;
|
|
const PHONE_SCOPE_OFF_ART_SIZE_MAX = 336;
|
|
const WIDE_MAX_CONTENT_WIDTH = 960;
|
|
export const NOW_PLAYING_WIDE_PANE_GAP = spacing.xxl;
|
|
const WIDE_RIGHT_PANE_MIN = 300;
|
|
const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH;
|
|
const WIDE_ART_SIZE_MAX = 400;
|
|
const WIDE_ART_SIZE_MIN = 160;
|
|
const WIDE_COMPACT_HEIGHT = 480;
|
|
const VISUALIZER_WIDTH_MAX = 448;
|
|
const VISUALIZER_SIDE_PADDING = spacing.md;
|
|
const VISUALIZER_TOP_GAP = spacing.lg;
|
|
const VISUALIZER_BOTTOM_GAP = spacing.sm;
|
|
const VISUALIZER_HEIGHT_MIN = 84;
|
|
const VISUALIZER_HEIGHT_MAX = 108;
|
|
const VISUALIZER_HEIGHT_RATIO = 0.28;
|
|
/** Fixed rail offset from the stage bottom, state-independent so the rail can
|
|
* stay mounted (and positioned) while its visibility animates. */
|
|
export const NOW_PLAYING_SCOPE_RAIL_BOTTOM_GAP = VISUALIZER_BOTTOM_GAP;
|
|
export const NOW_PLAYING_HEADER_HEIGHT = 32;
|
|
export const NOW_PLAYING_CONTENT_TOP_PADDING = spacing.sm;
|
|
export const NOW_PLAYING_CONTENT_BOTTOM_PADDING = spacing.lg;
|
|
const MEDIA_TOP_MARGIN = spacing.lg;
|
|
const MEDIA_BOTTOM_GAP = spacing.xl;
|
|
const TRACK_INFO_ESTIMATE = 96;
|
|
export const NOW_PLAYING_WAVEFORM_HEIGHT = 58;
|
|
export const NOW_PLAYING_WAVEFORM_TOUCH_PADDING = spacing.md;
|
|
const WAVEFORM_BLOCK_ESTIMATE =
|
|
NOW_PLAYING_WAVEFORM_HEIGHT + NOW_PLAYING_WAVEFORM_TOUCH_PADDING * 2 + 24;
|
|
export const NOW_PLAYING_PLAY_BUTTON_SIZE = 68;
|
|
const TRANSPORT_TOP_MARGIN = spacing.lg;
|
|
export const NOW_PLAYING_SUB_BUTTON_SIZE = 40;
|
|
const SUB_TOP_MARGIN = spacing.lg;
|
|
const MIN_FLOATING_SPACE = spacing.sm;
|
|
|
|
const TABLET_SHELL_MIN_WIDTH = 720;
|
|
const TABLET_SHELL_MAX_WIDTH = 1200;
|
|
const TABLET_COMPANION_GAP = spacing.xl;
|
|
const TABLET_COMPANION_MIN_WIDTH = 320;
|
|
const TABLET_COMPANION_MAX_WIDTH = 400;
|
|
const TABLET_STACKED_MIN_HEIGHT = 760;
|
|
const TABLET_WIDE_PLAYER_MIN_WIDTH = 600;
|
|
const TABLET_WIDE_MIN_HEIGHT = 520;
|
|
|
|
export type NowPlayingPresentation = 'standard' | 'wide';
|
|
|
|
export interface NowPlayingLayout {
|
|
presentation: NowPlayingPresentation;
|
|
isWide: boolean;
|
|
contentPadding: number;
|
|
contentWidth: number;
|
|
leftPaneWidth: number;
|
|
rightPaneWidth: number;
|
|
controlsGap: number;
|
|
trackInfoGap: number;
|
|
waveformHeight: number;
|
|
mediaStackHeight: number;
|
|
artSize: number;
|
|
/** Art size with the scope rail shown / hidden, regardless of the current
|
|
* state — both are returned so the rail toggle can animate between them. */
|
|
artSizeScopeOn: number;
|
|
artSizeScopeOff: number;
|
|
scopeWidth: number;
|
|
scopeHeight: number;
|
|
visualizerTopGap: number;
|
|
visualizerBottomGap: number;
|
|
mediaTopMargin: number;
|
|
mediaBottomGap: number;
|
|
}
|
|
|
|
export interface TabletCompanionLayout {
|
|
presentation: 'tablet-companion';
|
|
shellWidth: number;
|
|
playerRegionWidth: number;
|
|
companionWidth: number;
|
|
gap: number;
|
|
playerLayout: NowPlayingLayout;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
/** Natural scope strip height for a given width (~3.6:1, clamped). */
|
|
export function getScopeHeight(scopeWidth: number): number {
|
|
return Math.round(
|
|
clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Existing Now Playing layout calculator. Keep the numeric outputs stable for
|
|
* phone, split-screen, foldable, and short-landscape windows.
|
|
*/
|
|
export function getNowPlayingLayout(
|
|
availableWidth: number,
|
|
availableHeight: number,
|
|
showVisualizer: boolean,
|
|
forceWide = false
|
|
): NowPlayingLayout {
|
|
const isWide = forceWide || isWideWindow(availableWidth, availableHeight);
|
|
|
|
if (isWide) {
|
|
const contentPadding = CONTENT_SIDE_PADDING;
|
|
const contentWidth = Math.max(
|
|
0,
|
|
Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH)
|
|
);
|
|
const rightPaneWidth = Math.round(
|
|
clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX)
|
|
);
|
|
const leftPaneWidth = Math.max(
|
|
0,
|
|
contentWidth - NOW_PLAYING_WIDE_PANE_GAP - rightPaneWidth
|
|
);
|
|
const scopeWidth = Math.min(leftPaneWidth, VISUALIZER_WIDTH_MAX);
|
|
const scopeHeight = getScopeHeight(scopeWidth);
|
|
const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0;
|
|
const verticalBudget =
|
|
availableHeight -
|
|
NOW_PLAYING_CONTENT_TOP_PADDING -
|
|
NOW_PLAYING_CONTENT_BOTTOM_PADDING -
|
|
NOW_PLAYING_HEADER_HEIGHT -
|
|
spacing.md;
|
|
const wideArt = (budget: number) =>
|
|
Math.round(clamp(Math.min(leftPaneWidth, budget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX));
|
|
const artSizeScopeOn = wideArt(verticalBudget - (scopeHeight + VISUALIZER_TOP_GAP));
|
|
const artSizeScopeOff = wideArt(verticalBudget);
|
|
const artSize = showVisualizer ? artSizeScopeOn : artSizeScopeOff;
|
|
const controlsGap = availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg;
|
|
return {
|
|
presentation: 'wide',
|
|
isWide: true,
|
|
contentPadding,
|
|
contentWidth,
|
|
leftPaneWidth,
|
|
rightPaneWidth,
|
|
controlsGap,
|
|
trackInfoGap: spacing.md,
|
|
waveformHeight: NOW_PLAYING_WAVEFORM_HEIGHT,
|
|
mediaStackHeight: showVisualizer
|
|
? artSize + visualizerTopGap + scopeHeight
|
|
: artSize,
|
|
artSize,
|
|
artSizeScopeOn,
|
|
artSizeScopeOff,
|
|
scopeWidth,
|
|
scopeHeight,
|
|
visualizerTopGap,
|
|
visualizerBottomGap: 0,
|
|
mediaTopMargin: 0,
|
|
mediaBottomGap: 0,
|
|
};
|
|
}
|
|
|
|
const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH;
|
|
const contentPadding =
|
|
availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
|
const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH;
|
|
const contentWidth = Math.max(
|
|
0,
|
|
Math.min(availableWidth - contentPadding * 2, maxContentWidth)
|
|
);
|
|
const scopeWidth = Math.max(
|
|
0,
|
|
Math.min(availableWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX)
|
|
);
|
|
const scopeHeight = getScopeHeight(scopeWidth);
|
|
const mediaMax = Math.min(
|
|
contentWidth,
|
|
isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth
|
|
);
|
|
const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN);
|
|
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
|
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
|
const fixedHeightBase =
|
|
NOW_PLAYING_CONTENT_TOP_PADDING +
|
|
NOW_PLAYING_CONTENT_BOTTOM_PADDING +
|
|
NOW_PLAYING_HEADER_HEIGHT +
|
|
mediaTopMargin +
|
|
TRACK_INFO_ESTIMATE +
|
|
WAVEFORM_BLOCK_ESTIMATE +
|
|
TRANSPORT_TOP_MARGIN +
|
|
NOW_PLAYING_PLAY_BUTTON_SIZE +
|
|
SUB_TOP_MARGIN +
|
|
NOW_PLAYING_SUB_BUTTON_SIZE +
|
|
MIN_FLOATING_SPACE;
|
|
const bound = availableHeight - fixedHeightBase - mediaBottomGap;
|
|
const uncappedScopeOffArt = Math.round(
|
|
clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax)
|
|
);
|
|
const scopeOffArt = isTabletColumn
|
|
? uncappedScopeOffArt
|
|
: Math.min(uncappedScopeOffArt, PHONE_SCOPE_OFF_ART_SIZE_MAX);
|
|
const offSurplus = Math.max(0, bound - uncappedScopeOffArt);
|
|
const stretchUnit = Math.min(Math.floor(offSurplus / 5), spacing.md);
|
|
const waveformHeight = NOW_PLAYING_WAVEFORM_HEIGHT + stretchUnit * 2;
|
|
const scopeBlockHeight = VISUALIZER_TOP_GAP + scopeHeight + VISUALIZER_BOTTOM_GAP;
|
|
// Keep the old stage height even when the visualizer-off artwork is capped.
|
|
// This preserves the metadata/lower-content anchor across the toggle.
|
|
const mediaStackHeight = Math.max(uncappedScopeOffArt, 96 + scopeBlockHeight);
|
|
const scopeOnArt = mediaStackHeight - scopeBlockHeight;
|
|
const artSize = showVisualizer ? scopeOnArt : scopeOffArt;
|
|
const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0;
|
|
const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0;
|
|
|
|
return {
|
|
presentation: 'standard',
|
|
isWide: false,
|
|
contentPadding,
|
|
contentWidth,
|
|
leftPaneWidth: contentWidth,
|
|
rightPaneWidth: contentWidth,
|
|
controlsGap: TRANSPORT_TOP_MARGIN,
|
|
trackInfoGap: spacing.md,
|
|
waveformHeight,
|
|
mediaStackHeight,
|
|
artSize,
|
|
artSizeScopeOn: scopeOnArt,
|
|
artSizeScopeOff: scopeOffArt,
|
|
scopeWidth,
|
|
scopeHeight,
|
|
visualizerTopGap,
|
|
visualizerBottomGap,
|
|
mediaTopMargin,
|
|
mediaBottomGap,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Additive tablet tier. Returning null means the caller must use the existing
|
|
* single/wide layout unchanged.
|
|
*/
|
|
export function getTabletCompanionLayout(
|
|
availableWidth: number,
|
|
availableHeight: number,
|
|
showVisualizer: boolean
|
|
): TabletCompanionLayout | null {
|
|
const shellWidth = Math.min(
|
|
Math.max(0, availableWidth - CONTENT_SIDE_PADDING * 2),
|
|
TABLET_SHELL_MAX_WIDTH
|
|
);
|
|
if (shellWidth < TABLET_SHELL_MIN_WIDTH) return null;
|
|
|
|
const companionWidth = Math.round(
|
|
clamp(shellWidth * 0.34, TABLET_COMPANION_MIN_WIDTH, TABLET_COMPANION_MAX_WIDTH)
|
|
);
|
|
const playerRegionWidth = shellWidth - TABLET_COMPANION_GAP - companionWidth;
|
|
const canStack = availableHeight >= TABLET_STACKED_MIN_HEIGHT;
|
|
const canUseWidePlayer =
|
|
playerRegionWidth >= TABLET_WIDE_PLAYER_MIN_WIDTH &&
|
|
availableHeight >= TABLET_WIDE_MIN_HEIGHT;
|
|
if (!canStack && !canUseWidePlayer) return null;
|
|
|
|
const forceWide = canUseWidePlayer && availableWidth > availableHeight;
|
|
return {
|
|
presentation: 'tablet-companion',
|
|
shellWidth,
|
|
playerRegionWidth,
|
|
companionWidth,
|
|
gap: TABLET_COMPANION_GAP,
|
|
playerLayout: getNowPlayingLayout(
|
|
playerRegionWidth,
|
|
availableHeight,
|
|
showVisualizer,
|
|
forceWide
|
|
),
|
|
};
|
|
}
|