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 ), }; }