import { useMemo, useState } from 'react'; import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native'; import { Image } from 'expo-image'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { SlideInDown, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { FormatBadges } from '@/components/FormatBadge'; import { RemoteSourceBadge } from '@/components/RemoteSourceBadge'; import { MarqueeText } from '@/components/MarqueeText'; import { SeekBar } from '@/components/SeekBar'; import { WaveformSeekBar } from '@/components/WaveformSeekBar'; import { Visualizer } from '@/components/Visualizer'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker'; import { QueueTray } from '@/components/queue/QueueTray'; import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; import { colors, radius, spacing } from '@/theme'; import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive'; import { motion } from '@/theme/motion'; import { resolveNavigationArtist } from '@/library/artistGrouping'; import { useLibraryStore } from '@/stores/libraryStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlayerStore } from '@/stores/playerStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { useSettingsStore } from '@/stores/settingsStore'; import type { DbTrack } from '@/types/library'; import { cycleRepeat, seekTo, skipToNext, skipToPrevious, togglePlay, toggleShuffle } from '@/audio/playbackController'; import { desktopConnectionLabel, getDesktopPlaybackPresentation, getEffectivePlaybackPresentation, getPhonePlaybackPresentation, hostFromBaseUrl, } from '@/playback/playbackTargetPresentation'; const DISMISS_DISTANCE = 140; const DISMISS_VELOCITY = 1000; const MAX_CONTENT_WIDTH = 408; const CONTENT_SIDE_PADDING = spacing.lg; const NARROW_CONTENT_SIDE_PADDING = spacing.md; const MEDIA_AREA_MIN = 220; // Tablet-portrait tier: tall windows >= WIDE_MIN_WIDTH keep the single column but grow it. const TABLET_MAX_CONTENT_WIDTH = 520; const TABLET_ART_SIZE_MAX = 440; // Wide (landscape/desktop) tier: two panes, art left, controls right. const WIDE_MAX_CONTENT_WIDTH = 960; const 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; const HEADER_HEIGHT = 32; const CONTENT_TOP_PADDING = spacing.sm; const CONTENT_BOTTOM_PADDING = spacing.lg; const MEDIA_TOP_MARGIN = spacing.lg; const MEDIA_BOTTOM_GAP = spacing.xl; const TRACK_INFO_ESTIMATE = 96; const WAVEFORM_HEIGHT = 58; const WAVEFORM_TOUCH_PADDING = spacing.md; const WAVEFORM_BLOCK_ESTIMATE = WAVEFORM_HEIGHT + WAVEFORM_TOUCH_PADDING * 2 + 24; const PLAY_BUTTON_SIZE = 68; const SKIP_ICON_SIZE = 32; const PLAY_ICON_SIZE = 34; const TRANSPORT_TOP_MARGIN = spacing.lg; const SUB_BUTTON_SIZE = 40; const SUB_ICON_SIZE = 20; const SUB_TOP_MARGIN = spacing.lg; const MIN_FLOATING_SPACE = spacing.sm; const MENU_ANIMATION_IN_MS = 130; const MENU_ANIMATION_OUT_MS = 100; const MENU_ENTER_OFFSET_Y = -8; interface NowPlayingLayout { isWide: boolean; contentPadding: number; contentWidth: number; leftPaneWidth: number; rightPaneWidth: number; controlsGap: number; trackInfoGap: number; waveformHeight: number; mediaStackHeight: number; artSize: number; scopeWidth: number; scopeHeight: number; visualizerTopGap: number; visualizerBottomGap: number; mediaTopMargin: number; mediaBottomGap: number; } interface NowPlayingMenuItem { key: string; label: string; icon: keyof typeof Ionicons.glyphMap; onPress: () => void; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function getScopeHeight(scopeWidth: number): number { return Math.round( clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX) ); } function getNowPlayingLayout( availableWidth: number, availableHeight: number, showVisualizer: boolean ): NowPlayingLayout { const isWide = 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 - 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 - CONTENT_TOP_PADDING - CONTENT_BOTTOM_PADDING - HEADER_HEIGHT - spacing.md; const artHeightBudget = verticalBudget - (showVisualizer ? scopeHeight + visualizerTopGap : 0); const artSize = Math.round( clamp(Math.min(leftPaneWidth, artHeightBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX) ); const controlsGap = availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg; return { isWide: true, contentPadding, contentWidth, leftPaneWidth, rightPaneWidth, controlsGap, trackInfoGap: spacing.md, waveformHeight: WAVEFORM_HEIGHT, mediaStackHeight: showVisualizer ? artSize + visualizerTopGap + scopeHeight : artSize, artSize, scopeWidth, scopeHeight, visualizerTopGap, visualizerBottomGap: 0, mediaTopMargin: 0, mediaBottomGap: 0, }; } // Tall windows: single column. Tablet-width ones get a larger column and art cap. 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); // Art may grow to the full column width when the height budget allows it. 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 defaultMediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP; const mediaBottomGap = defaultMediaBottomGap; const fixedHeightBase = CONTENT_TOP_PADDING + CONTENT_BOTTOM_PADDING + HEADER_HEIGHT + mediaTopMargin + TRACK_INFO_ESTIMATE + WAVEFORM_BLOCK_ESTIMATE + TRANSPORT_TOP_MARGIN + PLAY_BUTTON_SIZE + SUB_TOP_MARGIN + SUB_BUTTON_SIZE + MIN_FLOATING_SPACE; // The Math.max(96, ...) floor lets art shrink below MEDIA_AREA_MIN in squat // windows (split-screen halves) instead of pushing the controls off-screen. const bound = availableHeight - fixedHeightBase - mediaBottomGap; const scopeOffArt = Math.round( clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax) ); // Roomy screens get a taller waveform; the rest of the spare space is // distributed between the control rows by flex (space-between), so no // height estimate error can pool as one gap above the controls. const offSurplus = Math.max(0, bound - scopeOffArt); const stretchUnit = Math.min(Math.floor(offSurplus / 5), spacing.md); const waveformHeight = WAVEFORM_HEIGHT + stretchUnit * 2; // The media stack keeps one locked height in both scope states — the scope // steals its space from the art alone, so toggling it moves nothing else. const scopeBlockHeight = VISUALIZER_TOP_GAP + scopeHeight + VISUALIZER_BOTTOM_GAP; const mediaStackHeight = Math.max(scopeOffArt, 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 { isWide: false, contentPadding, contentWidth, leftPaneWidth: contentWidth, rightPaneWidth: contentWidth, controlsGap: TRANSPORT_TOP_MARGIN, trackInfoGap: spacing.md, waveformHeight, mediaStackHeight, artSize, scopeWidth, scopeHeight, visualizerTopGap, visualizerBottomGap, mediaTopMargin, mediaBottomGap, }; } export default function NowPlayingScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const [queueOpen, setQueueOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [targetPickerOpen, setTargetPickerOpen] = useState(false); const [playlistActionTrack, setPlaylistActionTrack] = useState(null); const selectedTarget = usePlaybackTargetStore((s) => s.target); const scopeMode = useSettingsStore((s) => s.scopeMode); const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible); const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const libraryTracks = useLibraryStore((s) => s.tracks); const track = usePlayerStore((s) => s.currentTrack); const playbackState = usePlayerStore((s) => s.playbackState); const currentTime = usePlayerStore((s) => s.currentTime); const duration = usePlayerStore((s) => s.duration); const shuffle = usePlayerStore((s) => s.shuffle); const repeat = usePlayerStore((s) => s.repeat); const isFavorite = usePlaylistStore((s) => (track ? s.favoritePaths.has(track.path) : false)); const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite); const desktopConnection = useDesktopRemoteStore((s) => s.connection); const desktopConnectionState = useDesktopRemoteStore((s) => s.connectionState); const desktopSnapshot = useDesktopRemoteStore((s) => s.snapshot); const desktopQueue = useDesktopRemoteStore((s) => s.queue); const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl); const reconnectDesktop = useDesktopRemoteStore((s) => s.reconnect); const phonePresentation = getPhonePlaybackPresentation({ track, playbackState, currentTime, duration, }); const desktopPresentation = getDesktopPlaybackPresentation({ connection: desktopConnection, connectionState: desktopConnectionState, snapshot: desktopSnapshot, }); const activePresentation = getEffectivePlaybackPresentation({ selectedTarget, phone: phonePresentation, desktop: desktopPresentation, }); const isDesktopTarget = activePresentation.target === 'desktop'; const activeTrack = desktopSnapshot?.currentTrack ?? null; const isPlaying = activePresentation.playbackState === 'playing'; const isLoading = activePresentation.playbackState === 'loading'; const availableHeight = windowHeight - insets.top - insets.bottom; const effectiveWidth = windowWidth - insets.left - insets.right; const layout = getNowPlayingLayout( effectiveWidth, availableHeight, isDesktopTarget ? false : scopeStageVisible ); const source = activePresentation.sourceLabel; const shellRight = insets.right + layout.contentPadding + Math.max(0, (effectiveWidth - layout.contentPadding * 2 - layout.contentWidth) / 2); const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs; const libraryTrack = useMemo( () => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null), [libraryTracks, track] ); const artistName = track ? resolveNavigationArtist( libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null }, artistGroupingMode ) : ''; const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key; const navigateToArtist = () => { if (!artistName) return; router.dismissTo({ pathname: '/library/artist/[name]', params: { name: artistName }, }); }; const navigateToAlbum = () => { if (!albumKey) return; router.dismissTo({ pathname: '/library/album/[key]', params: { key: albumKey }, }); }; const menuItems: NowPlayingMenuItem[] = []; menuItems.push({ key: 'output', label: 'Choose output device', icon: isDesktopTarget ? 'desktop-outline' : 'phone-portrait-outline', onPress: () => { closeMenu(); setTargetPickerOpen(true); }, }); if (!isDesktopTarget && artistName) { menuItems.push({ key: 'artist', label: 'View artist', icon: 'person-outline', onPress: () => { closeMenu(); navigateToArtist(); }, }); } if (!isDesktopTarget && albumKey) { menuItems.push({ key: 'album', label: 'View album', icon: 'albums-outline', onPress: () => { closeMenu(); navigateToAlbum(); }, }); } if (!isDesktopTarget && libraryTrack) { menuItems.push({ key: 'add-to-playlist', label: 'Add to playlist...', icon: 'add-circle-outline', onPress: () => { closeMenu(); setPlaylistActionTrack(libraryTrack); }, }); } // Swipe down to minimize. The stack transition is disabled for this route, so // the sheet owns one continuous enter/exit animation instead of handing off to // a second native modal animation after release. const translateY = useSharedValue(0); const menuProgress = useSharedValue(0); // Belt-and-suspenders for deep-link entry (widget/notification → now-playing with no // history): `(tabs)` is the stack anchor (see root _layout unstable_settings), so back() // returns there; if somehow there's nothing to go back to, replace to the tabs home so // dismissing can never land on a blank screen. const dismiss = () => { if (router.canGoBack()) router.back(); else router.replace('/'); }; const finishCloseMenu = () => setMenuOpen(false); function openMenu() { if (menuItems.length === 0) return; menuProgress.value = 0; setMenuOpen(true); menuProgress.value = withTiming(1, { duration: MENU_ANIMATION_IN_MS }); } function closeMenu() { menuProgress.value = withTiming(0, { duration: MENU_ANIMATION_OUT_MS }, (finished) => { if (finished) runOnJS(finishCloseMenu)(); }); } const dismissSheet = (velocity = 0) => { translateY.value = withSpring( windowHeight, { damping: 28, stiffness: 240, velocity, overshootClamping: true, }, (finished) => { if (finished) runOnJS(dismiss)(); } ); }; const pan = Gesture.Pan() .activeOffsetY(14) // engage only on a downward drag .failOffsetY(-14) .failOffsetX([-24, 24]) // let the horizontal seek drag through .onUpdate((e) => { translateY.value = e.translationY > 0 ? e.translationY : 0; }) .onEnd((e) => { if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) { translateY.value = withSpring( windowHeight, { damping: 28, stiffness: 240, velocity: e.velocityY, overshootClamping: true, }, (finished) => { if (finished) runOnJS(dismiss)(); } ); } else { translateY.value = withTiming(0, motion.snap); } }); const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); const menuLayerStyle = useAnimatedStyle(() => ({ opacity: menuProgress.value, })); const menuCardStyle = useAnimatedStyle(() => ({ transform: [{ translateY: MENU_ENTER_OFFSET_Y * (1 - menuProgress.value) }], })); return ( dismissSheet()} hitSlop={12}> PLAYING FROM {source} {isDesktopTarget ? ( activeTrack ? ( {activePresentation.artworkUri ? ( ) : ( )} {activeTrack.title} {activeTrack.artist || activeTrack.album || activePresentation.deviceLabel} void sendDesktopControl('toggle-favorite')} accessibilityLabel={activeTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'} accessibilityState={{ selected: activeTrack.isFavorite }} > void sendDesktopControl('seek', seconds)} /> void sendDesktopControl('toggle-shuffle')} accessibilityLabel="Shuffle" accessibilityState={{ selected: Boolean(desktopSnapshot?.shuffle) }} > void sendDesktopControl('previous')} hitSlop={12} style={styles.transportMainBtn} accessibilityLabel="Previous" > void sendDesktopControl(isPlaying ? 'pause' : 'play')} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'} > void sendDesktopControl('next')} hitSlop={12} style={styles.transportMainBtn} accessibilityLabel="Next" > void sendDesktopControl('toggle-repeat')} accessibilityLabel="Repeat" accessibilityState={{ selected: desktopSnapshot?.repeat !== 'none' }} > {desktopSnapshot?.repeat === 'one' ? ( ) : ( )} {desktopConnectionLabel(desktopConnectionState)} {desktopSnapshot?.outputDeviceLabel?.trim() || (desktopConnection ? hostFromBaseUrl(desktopConnection.baseUrl) : '')} void reconnectDesktop()} accessibilityLabel="Reconnect to desktop" > {desktopQueue ? ( setQueueOpen(true)} accessibilityLabel="Desktop queue" > ) : null} ) : ( {desktopConnection ? 'Nothing playing on desktop' : 'No desktop paired'} {desktopConnection ? desktopConnectionLabel(desktopConnectionState) : 'Pair with Astra Desktop to control it here.'} desktopConnection ? void reconnectDesktop() : router.push('/desktop-remote' as never) } > {desktopConnection ? 'Reconnect' : 'Pair desktop'} ) ) : track ? ( {track.artworkData ? ( ) : ( )} {scopeStageVisible && ( useSettingsStore .getState() .setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum') } hitSlop={12} style={styles.scopeSwap} accessibilityRole="button" accessibilityLabel={`Showing ${ scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope' }. Tap to switch.`} > {scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'} )} {track.title} {track.artist} void toggleFavorite(track)} accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'} accessibilityState={{ selected: isFavorite }} > void seekTo(seconds)} /> void toggleShuffle()} accessibilityLabel="Shuffle" accessibilityState={{ selected: shuffle }} > void cycleRepeat()} accessibilityLabel="Repeat" accessibilityState={{ selected: repeat !== 'none' }} > {repeat === 'one' ? ( ) : ( )} void setScopeStageVisible(!scopeStageVisible)} accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'} accessibilityState={{ selected: scopeStageVisible }} > setQueueOpen(true)} accessibilityLabel="Queue" > ) : ( Nothing playing Start a track from Home. )} {menuOpen && menuItems.length > 0 && ( {menuItems.map((item) => ( [styles.menuItem, pressed && styles.menuItemPressed]} onPress={item.onPress} accessibilityRole="button" > {item.label} ))} )} setPlaylistActionTrack(null)} /> {queueOpen && ( isDesktopTarget ? ( setQueueOpen(false)} /> ) : ( setQueueOpen(false)} /> ) )} setTargetPickerOpen(false)} /> ); } const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: 'transparent', }, content: { flex: 1, backgroundColor: colors.bgPrimary, alignItems: 'center', }, shell: { flex: 1, }, header: { height: HEADER_HEIGHT, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, headerBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', }, headerSide: { width: 48, flexDirection: 'row', alignItems: 'center', }, headerActions: { justifyContent: 'flex-end', }, headerMid: { flex: 1, alignItems: 'center', }, eyebrow: { color: colors.textTertiary, letterSpacing: 1.5, fontSize: 10, }, source: { color: colors.textSecondary, marginTop: 1, }, menuLayer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, menuDismiss: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, menuCard: { position: 'absolute', width: 212, borderRadius: radius.md, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.bgSecondary, paddingVertical: spacing.xs, shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 8 }, elevation: 12, }, menuItem: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: spacing.md, paddingHorizontal: spacing.md, }, menuItemPressed: { opacity: 0.6, }, menuItemLabel: { flex: 1, }, player: { flex: 1, }, playerWide: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', columnGap: WIDE_PANE_GAP, }, middleStack: { width: '100%', alignItems: 'center', }, artButton: { alignItems: 'center', justifyContent: 'center', }, artCard: { borderRadius: radius.lg, backgroundColor: colors.bgTertiary, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, scopeRail: { alignSelf: 'center', justifyContent: 'center', overflow: 'hidden', }, scopeSwap: { position: 'absolute', top: spacing.xs, right: spacing.sm, flexDirection: 'row', alignItems: 'center', gap: spacing.xs, }, scopeSwapLabel: { color: colors.textTertiary, letterSpacing: 1.5, fontSize: 10, }, artImage: { width: '100%', height: '100%', }, trackInfo: { alignSelf: 'stretch', flexDirection: 'row', alignItems: 'center', gap: spacing.md, }, trackTextStack: { flex: 1, minWidth: 0, alignItems: 'flex-start', }, trackTitle: { alignSelf: 'stretch', }, trackTitleText: { textAlign: 'left', }, inlineActionBtn: { width: SUB_BUTTON_SIZE, height: SUB_BUTTON_SIZE, alignItems: 'center', justifyContent: 'center', }, trackMetaRow: { alignSelf: 'stretch', flexDirection: 'row', flexWrap: 'nowrap', alignItems: 'center', gap: spacing.sm, marginTop: spacing.xs, }, artistButton: { flex: 1, minWidth: 0, }, centered: { textAlign: 'center', }, artist: { color: colors.accentText, }, playerControls: { width: '100%', }, playerControlsFill: { flex: 1, justifyContent: 'space-between', }, transport: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, transportMainBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, transportSideBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, controlDisabled: { opacity: 0.35, }, playButton: { width: PLAY_BUTTON_SIZE, height: PLAY_BUTTON_SIZE, borderRadius: radius.pill, backgroundColor: colors.accent, alignItems: 'center', justifyContent: 'center', }, subRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.md, paddingHorizontal: spacing.sm, }, subBadges: { flex: 1, minWidth: 0, overflow: 'hidden', flexDirection: 'row', alignItems: 'center', gap: spacing.xs, }, subActions: { flexDirection: 'row', alignItems: 'center', flexShrink: 0, gap: spacing.lg, }, statusPill: { minHeight: 28, flexDirection: 'row', alignItems: 'center', gap: spacing.xs, borderRadius: radius.pill, backgroundColor: colors.glassBg, paddingHorizontal: spacing.sm, }, statusDot: { width: 7, height: 7, borderRadius: 4, }, remoteDetail: { flex: 1, minWidth: 0, }, subBtn: { width: SUB_BUTTON_SIZE, height: SUB_BUTTON_SIZE, alignItems: 'center', justifyContent: 'center', }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', }, emptyTitle: { marginTop: spacing.lg, }, emptyAction: { marginTop: spacing.lg, minHeight: 42, borderRadius: radius.pill, alignItems: 'center', justifyContent: 'center', paddingHorizontal: spacing.lg, backgroundColor: colors.glassBg, }, });