diff --git a/.gitignore b/.gitignore index fc72268..3bc4011 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ example modules/*/android/build/ modules/*/android/.gradle/ modules/*/android/.cxx/ +vendor/kotlinaudio/kotlin-audio/build/ HANDOFF.md DESIGN.md diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index a5f15d2..2bbfea4 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native'; import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; @@ -32,13 +33,86 @@ const SUB_CONTROLS: { icon: IconName; label: string }[] = [ const DISMISS_DISTANCE = 140; const DISMISS_VELOCITY = 1000; -const COMPACT_HEIGHT = 780; -const TIGHT_HEIGHT = 700; + +const MAX_CONTENT_WIDTH = 390; +const MEDIA_AREA_MIN = 220; +const MEDIA_AREA_MAX = 360; +const ART_SIZE_MAX = 340; +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_GAP = spacing.xxl; +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 SECONDARY_CONTROLS_MIN_HEIGHT = 660; + +interface NowPlayingLayout { + contentPadding: number; + contentWidth: number; + mediaHeight: number; + artSize: number; + mediaTopMargin: number; + mediaBottomGap: number; + showSecondaryControls: boolean; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function getNowPlayingLayout(windowWidth: number, availableHeight: number): NowPlayingLayout { + const contentPadding = windowWidth < 360 ? spacing.lg : spacing.xl; + const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH)); + const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX); + const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN); + const showSecondaryControls = availableHeight >= SECONDARY_CONTROLS_MIN_HEIGHT; + const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN; + const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP; + const secondaryHeight = showSecondaryControls ? SUB_TOP_MARGIN + SUB_BUTTON_SIZE : 0; + const fixedHeight = + CONTENT_TOP_PADDING + + CONTENT_BOTTOM_PADDING + + HEADER_HEIGHT + + mediaTopMargin + + mediaBottomGap + + TRACK_INFO_ESTIMATE + + WAVEFORM_BLOCK_ESTIMATE + + TRANSPORT_TOP_MARGIN + + PLAY_BUTTON_SIZE + + secondaryHeight + + MIN_FLOATING_SPACE; + const heightBoundMedia = availableHeight - fixedHeight; + const mediaHeight = Math.round(clamp(heightBoundMedia, mediaMin, mediaMax)); + const artSize = Math.min(mediaHeight, ART_SIZE_MAX); + + return { + contentPadding, + contentWidth, + mediaHeight, + artSize, + mediaTopMargin, + mediaBottomGap, + showSecondaryControls, + }; +} export default function NowPlayingScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const [showScopeStage, setShowScopeStage] = useState(false); const track = usePlayerStore((s) => s.currentTrack); const playbackState = usePlayerStore((s) => s.playbackState); const currentTime = usePlayerStore((s) => s.currentTime); @@ -47,25 +121,7 @@ export default function NowPlayingScreen() { const isPlaying = playbackState === 'playing'; const isLoading = playbackState === 'loading'; const availableHeight = windowHeight - insets.top - insets.bottom; - const isCompact = availableHeight < COMPACT_HEIGHT; - const isTight = availableHeight < TIGHT_HEIGHT; - const contentPadding = isTight ? spacing.lg : spacing.xl; - const contentWidth = windowWidth - contentPadding * 2; - const artSize = Math.round( - Math.min( - contentWidth, - isTight ? 128 : isCompact ? 200 : 296, - Math.max(isTight ? 96 : 144, availableHeight * (isTight ? 0.18 : isCompact ? 0.24 : 0.32)) - ) - ); - const visualizerHeight = isTight ? 58 : isCompact ? 76 : 96; - const waveformHeight = isTight ? 42 : isCompact ? 50 : 58; - const waveformTouchPadding = isTight ? spacing.xs : spacing.md; - const playButtonSize = isTight ? 58 : isCompact ? 62 : 68; - const skipIconSize = isTight ? 28 : 32; - const playIconSize = isTight ? 30 : 34; - const subButtonSize = isTight ? 36 : 40; - const subIconSize = isTight ? 18 : 20; + const layout = getNowPlayingLayout(windowWidth, availableHeight); const source = track?.album?.trim() ? track.album : 'Library'; // Swipe down to minimize. The stack transition is disabled for this route, so @@ -128,134 +184,167 @@ export default function NowPlayingScreen() { styles.content, contentStyle, { - paddingHorizontal: contentPadding, - paddingTop: insets.top + (isTight ? spacing.xs : spacing.sm), - paddingBottom: insets.bottom + (isTight ? spacing.sm : spacing.lg), + paddingHorizontal: layout.contentPadding, + paddingTop: insets.top + CONTENT_TOP_PADDING, + paddingBottom: insets.bottom + CONTENT_BOTTOM_PADDING, }, ]} > - - dismissSheet()} hitSlop={12}> - - - - - PLAYING FROM - - - {source} - + + + dismissSheet()} hitSlop={12}> + + + + + PLAYING FROM + + + {source} + + + + + - - - - - {track ? ( - <> - - - {track.artworkData ? ( - + {track ? ( + + setShowScopeStage((visible) => !visible)} + accessibilityRole="button" + accessibilityLabel={showScopeStage ? 'Show artwork' : 'Show visualizer'} + style={[ + styles.mediaArea, + { + height: layout.mediaHeight, + marginTop: layout.mediaTopMargin, + marginBottom: layout.mediaBottomGap, + }, + ]} + > + {showScopeStage ? ( + + + ) : ( - + + {track.artworkData ? ( + + ) : ( + + )} + + )} + + + + + {track.title} + + + {track.artist} + + + + + + + + + + + void seekTo(seconds)} + /> + + + + + + + + + + + + + + + {layout.showSecondaryControls && ( + + {SUB_CONTROLS.map((c) => ( + + + + ))} + )} - - - - - - {track.title} + ) : ( + + Nothing playing + + Start a track from Home. - - {track.artist} - - - - - - - void seekTo(seconds)} - /> - - - - - - - - - - - - - - - - - - {SUB_CONTROLS.map((c) => ( - - - - ))} - - - ) : ( - - Nothing playing - - Start a track from Home. - - - )} + )} + @@ -270,8 +359,13 @@ const styles = StyleSheet.create({ content: { flex: 1, backgroundColor: colors.bgPrimary, + alignItems: 'center', + }, + shell: { + flex: 1, }, header: { + height: HEADER_HEIGHT, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', @@ -295,23 +389,32 @@ const styles = StyleSheet.create({ color: colors.textSecondary, marginTop: 1, }, - artWrap: { + player: { + flex: 1, + }, + mediaArea: { + width: '100%', alignItems: 'center', justifyContent: 'center', }, - art: { + artCard: { borderRadius: radius.lg, backgroundColor: colors.bgTertiary, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, + scopeSurface: { + justifyContent: 'center', + overflow: 'hidden', + }, artImage: { width: '100%', height: '100%', }, trackInfo: { alignItems: 'center', + alignSelf: 'stretch', }, centered: { textAlign: 'center', @@ -321,18 +424,27 @@ const styles = StyleSheet.create({ marginTop: spacing.xs, }, badges: { + marginTop: spacing.md, }, progressBlock: { }, spacer: { flex: 1, + minHeight: MIN_FLOATING_SPACE, + }, + playerControls: { + width: '100%', }, transport: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + gap: TRANSPORT_GAP, + marginTop: TRANSPORT_TOP_MARGIN, }, playButton: { + width: PLAY_BUTTON_SIZE, + height: PLAY_BUTTON_SIZE, borderRadius: radius.pill, backgroundColor: colors.accent, alignItems: 'center', @@ -342,8 +454,12 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + marginTop: SUB_TOP_MARGIN, + paddingHorizontal: spacing.sm, }, subBtn: { + width: SUB_BUTTON_SIZE, + height: SUB_BUTTON_SIZE, alignItems: 'center', justifyContent: 'center', }, diff --git a/src/components/Visualizer.tsx b/src/components/Visualizer.tsx index a22b8d1..4b220ef 100644 --- a/src/components/Visualizer.tsx +++ b/src/components/Visualizer.tsx @@ -12,32 +12,47 @@ const POINTS = 120; type Mode = 'spectrum' | 'scope'; +interface VisualizerProps { + width: number; + height?: number; + interactive?: boolean; + showChrome?: boolean; + mode?: Mode; +} + /** - * Inline visualizer for the now-playing screen — no card chrome, it just lives - * in the layout. Tap anywhere on it to switch between the live filled-line - * Spectrum and the Scope (oscilloscope, placeholder until its native path lands). + * Visualizer for the now-playing screen. By default it remains an inline + * interactive component; inside the media stage it can render chrome-free so + * the parent owns artwork/scope switching without moving the surrounding UI. */ -export function Visualizer({ width, height = CANVAS_HEIGHT }: { width: number; height?: number }) { - const [mode, setMode] = useState('spectrum'); +export function Visualizer({ + width, + height = CANVAS_HEIGHT, + interactive = true, + showChrome = true, + mode: controlledMode, +}: VisualizerProps) { + const [uncontrolledMode, setUncontrolledMode] = useState('spectrum'); + const mode = controlledMode ?? uncontrolledMode; const scopeActive = useScopeActive(); const spectrumActive = scopeActive && mode === 'spectrum'; const values = useSpectrumCurve(POINTS, spectrumActive); - const toggle = () => setMode((m) => (m === 'spectrum' ? 'scope' : 'spectrum')); + const toggle = () => { + if (controlledMode) return; + setUncontrolledMode((m) => (m === 'spectrum' ? 'scope' : 'spectrum')); + }; - return ( - - - - {mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'} - - - + const content = ( + <> + {showChrome && ( + + + {mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'} + + + + )} {mode === 'spectrum' ? ( @@ -51,6 +66,25 @@ export function Visualizer({ width, height = CANVAS_HEIGHT }: { width: number; h )} + + ); + + if (!interactive) { + return ( + + {content} + + ); + } + + return ( + + {content} ); } @@ -59,6 +93,9 @@ const styles = StyleSheet.create({ wrap: { paddingVertical: spacing.xs, }, + stageWrap: { + overflow: 'hidden', + }, caption: { flexDirection: 'row', alignItems: 'center',