From 4b2382e3cb168930181373c7a3ebc580087de0d6 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:18:18 -0400 Subject: [PATCH] update nowplaying UI/UX --- src/app/now-playing.tsx | 429 ++++++++++++++++++++++-------------- src/stores/settingsStore.ts | 19 +- 2 files changed, 281 insertions(+), 167 deletions(-) diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index add2cf4..4368584 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -42,13 +42,20 @@ const NARROW_CONTENT_SIDE_PADDING = spacing.md; const MEDIA_AREA_MIN = 220; const MEDIA_AREA_MAX = 360; const ART_SIZE_MAX = 340; -const SCOPE_HEIGHT_MIN = 150; -const SCOPE_HEIGHT_MAX = 220; -const SCOPE_HEIGHT_RATIO = 9 / 16; +const VISUALIZER_ART_SCALE = 0.8; +const VISUALIZER_ART_SIZE_MIN = 176; +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 VISUALIZER_MEDIA_BOTTOM_GAP = spacing.sm; const MEDIA_BOTTOM_GAP = spacing.xl; const TRACK_INFO_ESTIMATE = 96; const WAVEFORM_HEIGHT = 58; @@ -57,40 +64,52 @@ const WAVEFORM_BLOCK_ESTIMATE = WAVEFORM_HEIGHT + WAVEFORM_TOUCH_PADDING * 2 + 2 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; scopeWidth: number; scopeHeight: number; + visualizerTopGap: number; + visualizerBottomGap: 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 { +function getScopeHeight(scopeWidth: number): number { + return Math.round( + clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX) + ); +} + +function getNowPlayingLayout( + windowWidth: number, + availableHeight: number, + showVisualizer: boolean +): NowPlayingLayout { const contentPadding = windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING; const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH)); + const scopeWidth = Math.max( + 0, + Math.min(windowWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX) + ); + const scopeHeight = getScopeHeight(scopeWidth); 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 defaultMediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP; + const mediaBottomGap = showVisualizer ? VISUALIZER_MEDIA_BOTTOM_GAP : defaultMediaBottomGap; const fixedHeight = CONTENT_TOP_PADDING + CONTENT_BOTTOM_PADDING + @@ -101,27 +120,37 @@ function getNowPlayingLayout(windowWidth: number, availableHeight: number): NowP WAVEFORM_BLOCK_ESTIMATE + TRANSPORT_TOP_MARGIN + PLAY_BUTTON_SIZE + - secondaryHeight + + SUB_TOP_MARGIN + + SUB_BUTTON_SIZE + MIN_FLOATING_SPACE; const heightBoundMedia = availableHeight - fixedHeight; - const mediaHeight = Math.round(clamp(heightBoundMedia, mediaMin, mediaMax)); - const artSize = Math.min(mediaHeight, ART_SIZE_MAX); - const scopeWidth = contentWidth; - const scopeHeight = Math.min( - mediaHeight, - Math.round(clamp(scopeWidth * SCOPE_HEIGHT_RATIO, SCOPE_HEIGHT_MIN, SCOPE_HEIGHT_MAX)) + const baseArtSize = Math.min( + Math.round(clamp(heightBoundMedia, mediaMin, mediaMax)), + ART_SIZE_MAX ); + const artSize = showVisualizer + ? Math.round( + clamp( + baseArtSize * VISUALIZER_ART_SCALE, + Math.min(VISUALIZER_ART_SIZE_MIN, mediaMax), + mediaMax + ) + ) + : baseArtSize; + + const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; + const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0; return { contentPadding, contentWidth, - mediaHeight, artSize, scopeWidth, scopeHeight, + visualizerTopGap, + visualizerBottomGap, mediaTopMargin, mediaBottomGap, - showSecondaryControls, }; } @@ -129,9 +158,10 @@ export default function NowPlayingScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); - const [showScopeStage, setShowScopeStage] = useState(false); const [queueOpen, setQueueOpen] = useState(false); const scopeMode = useSettingsStore((s) => s.scopeMode); + const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible); + const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); const track = usePlayerStore((s) => s.currentTrack); const playbackState = usePlayerStore((s) => s.playbackState); const currentTime = usePlayerStore((s) => s.currentTime); @@ -144,7 +174,7 @@ export default function NowPlayingScreen() { const isPlaying = playbackState === 'playing'; const isLoading = playbackState === 'loading'; const availableHeight = windowHeight - insets.top - insets.bottom; - const layout = getNowPlayingLayout(windowWidth, availableHeight); + const layout = getNowPlayingLayout(windowWidth, availableHeight, scopeStageVisible); const source = track?.album?.trim() ? track.album : 'Library'; // Swipe down to minimize. The stack transition is disabled for this route, so @@ -233,60 +263,24 @@ export default function NowPlayingScreen() { {track ? ( - setShowScopeStage((visible) => !visible)} - accessibilityRole="button" - accessibilityLabel={showScopeStage ? 'Show artwork' : 'Show visualizer'} + - {showScopeStage ? ( - - - {/* Nested Pressable: RN grants the responder to this - button, so a swap tap does not also collapse the - stage (the outer Pressable's onPress won't fire). */} - - 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} - - - + + {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'} + + + + + )} - - void seekTo(seconds)} - /> + + + + {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' ? ( + + ) : ( + + )} + - {layout.showSecondaryControls && ( - - void toggleShuffle()} - accessibilityLabel="Shuffle" - accessibilityState={{ selected: shuffle }} - > - - - void toggleFavorite(track)} - accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'} - accessibilityState={{ selected: isFavorite }} - > - - - setQueueOpen(true)} - accessibilityLabel="Queue" - > - - - 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" + > + + + ) : ( @@ -483,9 +539,12 @@ const styles = StyleSheet.create({ player: { flex: 1, }, - mediaArea: { + middleStack: { width: '100%', alignItems: 'center', + }, + artButton: { + alignItems: 'center', justifyContent: 'center', }, artCard: { @@ -495,7 +554,8 @@ const styles = StyleSheet.create({ justifyContent: 'center', overflow: 'hidden', }, - scopeSurface: { + scopeRail: { + alignSelf: 'center', justifyContent: 'center', overflow: 'hidden', }, @@ -517,20 +577,45 @@ const styles = StyleSheet.create({ height: '100%', }, trackInfo: { - alignItems: 'center', alignSelf: 'stretch', + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginBottom: spacing.md, + }, + trackTextStack: { + flex: 1, + minWidth: 0, + alignItems: 'flex-start', + }, + trackTitle: { + alignSelf: 'stretch', + textAlign: 'left', + }, + inlineActionBtn: { + width: SUB_BUTTON_SIZE, + height: SUB_BUTTON_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + trackMetaRow: { + alignSelf: 'stretch', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: spacing.sm, + marginTop: spacing.xs, }, centered: { textAlign: 'center', }, artist: { color: colors.accentText, - marginTop: spacing.xs, + flexShrink: 1, + minWidth: 0, }, badges: { - marginTop: spacing.md, - }, - progressBlock: { + flexShrink: 0, }, spacer: { flex: 1, @@ -542,10 +627,21 @@ const styles = StyleSheet.create({ transport: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - gap: TRANSPORT_GAP, + justifyContent: 'space-between', marginTop: TRANSPORT_TOP_MARGIN, }, + transportMainBtn: { + width: 48, + height: 48, + alignItems: 'center', + justifyContent: 'center', + }, + transportSideBtn: { + width: 48, + height: 48, + alignItems: 'center', + justifyContent: 'center', + }, playButton: { width: PLAY_BUTTON_SIZE, height: PLAY_BUTTON_SIZE, @@ -557,7 +653,8 @@ const styles = StyleSheet.create({ subRow: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + justifyContent: 'flex-end', + gap: spacing.lg, marginTop: SUB_TOP_MARGIN, paddingHorizontal: spacing.sm, }, diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 278aa8e..dc4d6b7 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -10,6 +10,7 @@ import type { ArtistGroupingMode } from '@/library/artistGrouping'; */ const ARTIST_GROUPING_KEY = 'artist_grouping_mode'; const SCOPE_MODE_KEY = 'scope_mode'; +const SCOPE_STAGE_VISIBLE_KEY = 'scope_stage_visible'; /** Which visualizer the now-playing scope stage shows. */ export type ScopeMode = 'spectrum' | 'scope'; @@ -22,30 +23,39 @@ function parseScopeMode(value: string | null): ScopeMode { return value === 'scope' ? 'scope' : 'spectrum'; } +function parseBoolean(value: string | null): boolean { + return value === 'true'; +} + interface SettingsStore { artistGroupingMode: ArtistGroupingMode; scopeMode: ScopeMode; + scopeStageVisible: boolean; loaded: boolean; load: () => Promise; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise; setScopeMode: (mode: ScopeMode) => Promise; + setScopeStageVisible: (visible: boolean) => Promise; } export const useSettingsStore = create((set, get) => ({ artistGroupingMode: 'astra', scopeMode: 'spectrum', + scopeStageVisible: false, loaded: false, load: async () => { if (get().loaded) return; const db = await openLibraryDb(); - const [grouping, scope] = await Promise.all([ + const [grouping, scope, scopeStageVisible] = await Promise.all([ getSetting(db, ARTIST_GROUPING_KEY), getSetting(db, SCOPE_MODE_KEY), + getSetting(db, SCOPE_STAGE_VISIBLE_KEY), ]); set({ artistGroupingMode: parseGroupingMode(grouping), scopeMode: parseScopeMode(scope), + scopeStageVisible: parseBoolean(scopeStageVisible), loaded: true, }); }, @@ -63,4 +73,11 @@ export const useSettingsStore = create((set, get) => ({ const db = await openLibraryDb(); await setSetting(db, SCOPE_MODE_KEY, mode); }, + + setScopeStageVisible: async (visible) => { + if (get().scopeStageVisible === visible) return; + set({ scopeStageVisible: visible }); + const db = await openLibraryDb(); + await setSetting(db, SCOPE_STAGE_VISIBLE_KEY, visible ? 'true' : 'false'); + }, }));