update nowplaying UI/UX

This commit is contained in:
Boof2015
2026-06-18 17:18:18 -04:00
parent 9fd37c93d7
commit 4b2382e3cb
2 changed files with 281 additions and 167 deletions
+263 -166
View File
@@ -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 ? (
<View style={styles.player}>
<Pressable
onPress={() => setShowScopeStage((visible) => !visible)}
accessibilityRole="button"
accessibilityLabel={showScopeStage ? 'Show artwork' : 'Show visualizer'}
<View
style={[
styles.mediaArea,
styles.middleStack,
{
height: layout.mediaHeight,
marginTop: layout.mediaTopMargin,
marginBottom: layout.mediaBottomGap,
},
]}
>
{showScopeStage ? (
<View
style={[
styles.scopeSurface,
{
width: layout.scopeWidth,
height: layout.scopeHeight,
},
]}
>
<Visualizer
width={layout.scopeWidth}
height={layout.scopeHeight}
interactive={false}
showChrome={false}
mode={scopeMode}
edgeFade
/>
{/* 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). */}
<Pressable
onPress={() =>
useSettingsStore
.getState()
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
}
hitSlop={12}
style={styles.scopeSwap}
accessibilityRole="button"
accessibilityLabel={`Showing ${
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
}. Tap to switch.`}
>
<Text variant="caption" style={styles.scopeSwapLabel}>
{scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
</Text>
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
</Pressable>
</View>
) : (
<View
style={[
styles.artButton,
{
width: layout.artSize,
height: layout.artSize,
},
]}
>
<View
style={[
styles.artCard,
@@ -307,38 +301,111 @@ export default function NowPlayingScreen() {
<AstraLogo size={Math.round(layout.artSize * 0.4)} />
)}
</View>
)}
</Pressable>
<View style={styles.trackInfo}>
<Text variant="heading" numberOfLines={2} style={styles.centered}>
{track.title}
</Text>
<Text variant="body" numberOfLines={1} style={[styles.centered, styles.artist]}>
{track.artist}
</Text>
<View style={styles.badges}>
<FormatBadges track={track} />
</View>
{scopeStageVisible && (
<View
style={[
styles.scopeRail,
{
width: layout.scopeWidth,
height: layout.scopeHeight,
marginTop: layout.visualizerTopGap,
marginBottom: layout.visualizerBottomGap,
},
]}
>
<Visualizer
width={layout.scopeWidth}
height={layout.scopeHeight}
interactive={false}
showChrome={false}
mode={scopeMode}
edgeFade
/>
<Pressable
onPress={() =>
useSettingsStore
.getState()
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
}
hitSlop={12}
style={styles.scopeSwap}
accessibilityRole="button"
accessibilityLabel={`Showing ${
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
}. Tap to switch.`}
>
<Text variant="caption" style={styles.scopeSwapLabel}>
{scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
</Text>
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
</Pressable>
</View>
)}
</View>
<View style={styles.spacer} />
<View style={styles.playerControls}>
<View style={styles.progressBlock}>
<WaveformSeekBar
currentTime={currentTime}
duration={duration}
height={WAVEFORM_HEIGHT}
touchPadding={WAVEFORM_TOUCH_PADDING}
trackKey={track.id}
trackPath={track.path}
onSeek={(seconds) => void seekTo(seconds)}
/>
<View style={styles.trackInfo}>
<View style={styles.trackTextStack}>
<Text variant="heading" numberOfLines={1} style={styles.trackTitle}>
{track.title}
</Text>
<View style={styles.trackMetaRow}>
<Text variant="body" numberOfLines={1} style={styles.artist}>
{track.artist}
</Text>
<View style={styles.badges}>
<FormatBadges track={track} />
</View>
</View>
</View>
<Pressable
hitSlop={10}
style={styles.inlineActionBtn}
onPress={() => void toggleFavorite(track)}
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
accessibilityState={{ selected: isFavorite }}
>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={SUB_ICON_SIZE + 4}
color={isFavorite ? colors.accent : colors.textTertiary}
/>
</Pressable>
</View>
<WaveformSeekBar
currentTime={currentTime}
duration={duration}
height={WAVEFORM_HEIGHT}
touchPadding={WAVEFORM_TOUCH_PADDING}
trackKey={track.id}
trackPath={track.path}
onSeek={(seconds) => void seekTo(seconds)}
/>
<View style={styles.transport}>
<Pressable onPress={skipToPrevious} hitSlop={12}>
<Pressable
hitSlop={10}
style={styles.transportSideBtn}
onPress={() => void toggleShuffle()}
accessibilityLabel="Shuffle"
accessibilityState={{ selected: shuffle }}
>
<Ionicons
name="shuffle"
size={SUB_ICON_SIZE + 2}
color={shuffle ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
onPress={skipToPrevious}
hitSlop={12}
style={styles.transportMainBtn}
>
<Ionicons
name="play-skip-back"
size={SKIP_ICON_SIZE}
@@ -352,78 +419,67 @@ export default function NowPlayingScreen() {
color={colors.bgPrimary}
/>
</Pressable>
<Pressable onPress={skipToNext} hitSlop={12}>
<Pressable
onPress={skipToNext}
hitSlop={12}
style={styles.transportMainBtn}
>
<Ionicons
name="play-skip-forward"
size={SKIP_ICON_SIZE}
color={colors.textPrimary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.transportSideBtn}
onPress={() => void cycleRepeat()}
accessibilityLabel="Repeat"
accessibilityState={{ selected: repeat !== 'none' }}
>
{repeat === 'one' ? (
<MaterialCommunityIcons
name="repeat-once"
size={SUB_ICON_SIZE + 2}
color={colors.accent}
/>
) : (
<Ionicons
name="repeat"
size={SUB_ICON_SIZE + 2}
color={repeat === 'all' ? colors.accent : colors.textTertiary}
/>
)}
</Pressable>
</View>
{layout.showSecondaryControls && (
<View style={styles.subRow}>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => void toggleShuffle()}
accessibilityLabel="Shuffle"
accessibilityState={{ selected: shuffle }}
>
<Ionicons
name="shuffle"
size={SUB_ICON_SIZE}
color={shuffle ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => void toggleFavorite(track)}
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
accessibilityState={{ selected: isFavorite }}
>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={SUB_ICON_SIZE}
color={isFavorite ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => setQueueOpen(true)}
accessibilityLabel="Queue"
>
<Ionicons
name="list-outline"
size={SUB_ICON_SIZE}
color={colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => void cycleRepeat()}
accessibilityLabel="Repeat"
accessibilityState={{ selected: repeat !== 'none' }}
>
{repeat === 'one' ? (
<MaterialCommunityIcons
name="repeat-once"
size={SUB_ICON_SIZE}
color={colors.accent}
/>
) : (
<Ionicons
name="repeat"
size={SUB_ICON_SIZE}
color={repeat === 'all' ? colors.accent : colors.textTertiary}
/>
)}
</Pressable>
</View>
)}
<View style={styles.subRow}>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
accessibilityState={{ selected: scopeStageVisible }}
>
<MaterialCommunityIcons
name="sine-wave"
size={SUB_ICON_SIZE + 2}
color={scopeStageVisible ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => setQueueOpen(true)}
accessibilityLabel="Queue"
>
<Ionicons
name="list-outline"
size={SUB_ICON_SIZE + 2}
color={colors.textTertiary}
/>
</Pressable>
</View>
</View>
</View>
) : (
@@ -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,
},