mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
better now playing UI consistency
This commit is contained in:
@@ -46,6 +46,7 @@ example
|
|||||||
modules/*/android/build/
|
modules/*/android/build/
|
||||||
modules/*/android/.gradle/
|
modules/*/android/.gradle/
|
||||||
modules/*/android/.cxx/
|
modules/*/android/.cxx/
|
||||||
|
vendor/kotlinaudio/kotlin-audio/build/
|
||||||
|
|
||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
DESIGN.md
|
DESIGN.md
|
||||||
|
|||||||
+256
-140
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
|
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -32,13 +33,86 @@ const SUB_CONTROLS: { icon: IconName; label: string }[] = [
|
|||||||
|
|
||||||
const DISMISS_DISTANCE = 140;
|
const DISMISS_DISTANCE = 140;
|
||||||
const DISMISS_VELOCITY = 1000;
|
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() {
|
export default function NowPlayingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
|
const [showScopeStage, setShowScopeStage] = useState(false);
|
||||||
const track = usePlayerStore((s) => s.currentTrack);
|
const track = usePlayerStore((s) => s.currentTrack);
|
||||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
@@ -47,25 +121,7 @@ export default function NowPlayingScreen() {
|
|||||||
const isPlaying = playbackState === 'playing';
|
const isPlaying = playbackState === 'playing';
|
||||||
const isLoading = playbackState === 'loading';
|
const isLoading = playbackState === 'loading';
|
||||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
const availableHeight = windowHeight - insets.top - insets.bottom;
|
||||||
const isCompact = availableHeight < COMPACT_HEIGHT;
|
const layout = getNowPlayingLayout(windowWidth, availableHeight);
|
||||||
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 source = track?.album?.trim() ? track.album : 'Library';
|
const source = track?.album?.trim() ? track.album : 'Library';
|
||||||
|
|
||||||
// Swipe down to minimize. The stack transition is disabled for this route, so
|
// Swipe down to minimize. The stack transition is disabled for this route, so
|
||||||
@@ -128,134 +184,167 @@ export default function NowPlayingScreen() {
|
|||||||
styles.content,
|
styles.content,
|
||||||
contentStyle,
|
contentStyle,
|
||||||
{
|
{
|
||||||
paddingHorizontal: contentPadding,
|
paddingHorizontal: layout.contentPadding,
|
||||||
paddingTop: insets.top + (isTight ? spacing.xs : spacing.sm),
|
paddingTop: insets.top + CONTENT_TOP_PADDING,
|
||||||
paddingBottom: insets.bottom + (isTight ? spacing.sm : spacing.lg),
|
paddingBottom: insets.bottom + CONTENT_BOTTOM_PADDING,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.header}>
|
<View style={[styles.shell, { width: layout.contentWidth }]}>
|
||||||
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
|
<View style={styles.header}>
|
||||||
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
|
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
|
||||||
</Pressable>
|
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
|
||||||
<View style={styles.headerMid}>
|
</Pressable>
|
||||||
<Text variant="caption" style={styles.eyebrow}>
|
<View style={styles.headerMid}>
|
||||||
PLAYING FROM
|
<Text variant="caption" style={styles.eyebrow}>
|
||||||
</Text>
|
PLAYING FROM
|
||||||
<Text variant="label" numberOfLines={1} style={styles.source}>
|
</Text>
|
||||||
{source}
|
<Text variant="label" numberOfLines={1} style={styles.source}>
|
||||||
</Text>
|
{source}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable style={styles.headerBtn} hitSlop={12} accessibilityLabel="More options">
|
||||||
|
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<Pressable style={styles.headerBtn} hitSlop={12} accessibilityLabel="More options">
|
|
||||||
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{track ? (
|
{track ? (
|
||||||
<>
|
<View style={styles.player}>
|
||||||
<View
|
<Pressable
|
||||||
style={[
|
onPress={() => setShowScopeStage((visible) => !visible)}
|
||||||
styles.artWrap,
|
accessibilityRole="button"
|
||||||
{
|
accessibilityLabel={showScopeStage ? 'Show artwork' : 'Show visualizer'}
|
||||||
marginTop: isTight ? spacing.xs : spacing.lg,
|
style={[
|
||||||
marginBottom: isTight ? spacing.xs : spacing.lg,
|
styles.mediaArea,
|
||||||
},
|
{
|
||||||
]}
|
height: layout.mediaHeight,
|
||||||
>
|
marginTop: layout.mediaTopMargin,
|
||||||
<View style={[styles.art, { width: artSize, height: artSize }]}>
|
marginBottom: layout.mediaBottomGap,
|
||||||
{track.artworkData ? (
|
},
|
||||||
<Image
|
]}
|
||||||
source={{ uri: track.artworkData }}
|
>
|
||||||
style={styles.artImage}
|
{showScopeStage ? (
|
||||||
contentFit="cover"
|
<View
|
||||||
/>
|
style={[
|
||||||
|
styles.scopeSurface,
|
||||||
|
{
|
||||||
|
width: layout.contentWidth,
|
||||||
|
height: layout.mediaHeight,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Visualizer
|
||||||
|
width={layout.contentWidth}
|
||||||
|
height={layout.mediaHeight}
|
||||||
|
interactive={false}
|
||||||
|
showChrome={false}
|
||||||
|
mode="spectrum"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<AstraLogo size={Math.round(artSize * 0.4)} />
|
<View
|
||||||
|
style={[
|
||||||
|
styles.artCard,
|
||||||
|
{
|
||||||
|
width: layout.artSize,
|
||||||
|
height: layout.artSize,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{track.artworkData ? (
|
||||||
|
<Image
|
||||||
|
key={track.id}
|
||||||
|
source={{ uri: track.artworkData }}
|
||||||
|
style={styles.artImage}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<View style={styles.transport}>
|
||||||
|
<Pressable onPress={skipToPrevious} hitSlop={12}>
|
||||||
|
<Ionicons
|
||||||
|
name="play-skip-back"
|
||||||
|
size={SKIP_ICON_SIZE}
|
||||||
|
color={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={togglePlay} hitSlop={12} style={styles.playButton}>
|
||||||
|
<Ionicons
|
||||||
|
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||||
|
size={PLAY_ICON_SIZE}
|
||||||
|
color={colors.bgPrimary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={skipToNext} hitSlop={12}>
|
||||||
|
<Ionicons
|
||||||
|
name="play-skip-forward"
|
||||||
|
size={SKIP_ICON_SIZE}
|
||||||
|
color={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{layout.showSecondaryControls && (
|
||||||
|
<View style={styles.subRow}>
|
||||||
|
{SUB_CONTROLS.map((c) => (
|
||||||
|
<Pressable
|
||||||
|
key={c.label}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.subBtn}
|
||||||
|
accessibilityLabel={c.label}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={c.icon}
|
||||||
|
size={SUB_ICON_SIZE}
|
||||||
|
color={colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
) : (
|
||||||
<Visualizer width={contentWidth} height={visualizerHeight} />
|
<View style={styles.empty}>
|
||||||
|
<Text variant="heading">Nothing playing</Text>
|
||||||
<View style={[styles.trackInfo, { marginTop: isTight ? spacing.xs : spacing.md }]}>
|
<Text variant="body" color={colors.textSecondary} style={styles.centered}>
|
||||||
<Text variant="heading" numberOfLines={2} style={styles.centered}>
|
Start a track from Home.
|
||||||
{track.title}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="body" numberOfLines={1} style={[styles.centered, styles.artist]}>
|
|
||||||
{track.artist}
|
|
||||||
</Text>
|
|
||||||
<View style={[styles.badges, { marginTop: isTight ? spacing.sm : spacing.md }]}>
|
|
||||||
<FormatBadges track={track} />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
<View style={[styles.progressBlock, { marginTop: isTight ? spacing.sm : spacing.lg }]}>
|
</View>
|
||||||
<WaveformSeekBar
|
|
||||||
currentTime={currentTime}
|
|
||||||
duration={duration}
|
|
||||||
height={waveformHeight}
|
|
||||||
touchPadding={waveformTouchPadding}
|
|
||||||
trackKey={track.id}
|
|
||||||
trackPath={track.path}
|
|
||||||
onSeek={(seconds) => void seekTo(seconds)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.spacer, { minHeight: isTight ? spacing.xs : spacing.md }]} />
|
|
||||||
|
|
||||||
<View style={[styles.transport, { gap: isTight ? spacing.xl : spacing.xxl }]}>
|
|
||||||
<Pressable onPress={skipToPrevious} hitSlop={12}>
|
|
||||||
<Ionicons name="play-skip-back" size={skipIconSize} color={colors.textPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={togglePlay}
|
|
||||||
hitSlop={12}
|
|
||||||
style={[styles.playButton, { width: playButtonSize, height: playButtonSize }]}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
|
||||||
size={playIconSize}
|
|
||||||
color={colors.bgPrimary}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={skipToNext} hitSlop={12}>
|
|
||||||
<Ionicons
|
|
||||||
name="play-skip-forward"
|
|
||||||
size={skipIconSize}
|
|
||||||
color={colors.textPrimary}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.subRow,
|
|
||||||
{
|
|
||||||
marginTop: isTight ? spacing.sm : spacing.lg,
|
|
||||||
paddingHorizontal: isTight ? 0 : spacing.sm,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{SUB_CONTROLS.map((c) => (
|
|
||||||
<Pressable
|
|
||||||
key={c.label}
|
|
||||||
hitSlop={10}
|
|
||||||
style={[styles.subBtn, { width: subButtonSize, height: subButtonSize }]}
|
|
||||||
accessibilityLabel={c.label}
|
|
||||||
>
|
|
||||||
<Ionicons name={c.icon} size={subIconSize} color={colors.textTertiary} />
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Text variant="heading">Nothing playing</Text>
|
|
||||||
<Text variant="body" color={colors.textSecondary} style={styles.centered}>
|
|
||||||
Start a track from Home.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
</View>
|
</View>
|
||||||
@@ -270,8 +359,13 @@ const styles = StyleSheet.create({
|
|||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.bgPrimary,
|
backgroundColor: colors.bgPrimary,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
|
height: HEADER_HEIGHT,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -295,23 +389,32 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
artWrap: {
|
player: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
mediaArea: {
|
||||||
|
width: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
art: {
|
artCard: {
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
backgroundColor: colors.bgTertiary,
|
backgroundColor: colors.bgTertiary,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
scopeSurface: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
artImage: {
|
artImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
trackInfo: {
|
trackInfo: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
alignSelf: 'stretch',
|
||||||
},
|
},
|
||||||
centered: {
|
centered: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@@ -321,18 +424,27 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
badges: {
|
badges: {
|
||||||
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
progressBlock: {
|
progressBlock: {
|
||||||
},
|
},
|
||||||
spacer: {
|
spacer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: MIN_FLOATING_SPACE,
|
||||||
|
},
|
||||||
|
playerControls: {
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
transport: {
|
transport: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
gap: TRANSPORT_GAP,
|
||||||
|
marginTop: TRANSPORT_TOP_MARGIN,
|
||||||
},
|
},
|
||||||
playButton: {
|
playButton: {
|
||||||
|
width: PLAY_BUTTON_SIZE,
|
||||||
|
height: PLAY_BUTTON_SIZE,
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.pill,
|
||||||
backgroundColor: colors.accent,
|
backgroundColor: colors.accent,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -342,8 +454,12 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
marginTop: SUB_TOP_MARGIN,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
},
|
},
|
||||||
subBtn: {
|
subBtn: {
|
||||||
|
width: SUB_BUTTON_SIZE,
|
||||||
|
height: SUB_BUTTON_SIZE,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,32 +12,47 @@ const POINTS = 120;
|
|||||||
|
|
||||||
type Mode = 'spectrum' | 'scope';
|
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
|
* Visualizer for the now-playing screen. By default it remains an inline
|
||||||
* in the layout. Tap anywhere on it to switch between the live filled-line
|
* interactive component; inside the media stage it can render chrome-free so
|
||||||
* Spectrum and the Scope (oscilloscope, placeholder until its native path lands).
|
* the parent owns artwork/scope switching without moving the surrounding UI.
|
||||||
*/
|
*/
|
||||||
export function Visualizer({ width, height = CANVAS_HEIGHT }: { width: number; height?: number }) {
|
export function Visualizer({
|
||||||
const [mode, setMode] = useState<Mode>('spectrum');
|
width,
|
||||||
|
height = CANVAS_HEIGHT,
|
||||||
|
interactive = true,
|
||||||
|
showChrome = true,
|
||||||
|
mode: controlledMode,
|
||||||
|
}: VisualizerProps) {
|
||||||
|
const [uncontrolledMode, setUncontrolledMode] = useState<Mode>('spectrum');
|
||||||
|
const mode = controlledMode ?? uncontrolledMode;
|
||||||
const scopeActive = useScopeActive();
|
const scopeActive = useScopeActive();
|
||||||
const spectrumActive = scopeActive && mode === 'spectrum';
|
const spectrumActive = scopeActive && mode === 'spectrum';
|
||||||
const values = useSpectrumCurve(POINTS, spectrumActive);
|
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 (
|
const content = (
|
||||||
<Pressable
|
<>
|
||||||
onPress={toggle}
|
{showChrome && (
|
||||||
style={[styles.wrap, { width }]}
|
<View style={styles.caption}>
|
||||||
accessibilityRole="button"
|
<Text variant="caption" style={styles.captionText}>
|
||||||
accessibilityLabel={`Visualizer showing ${mode}. Tap to switch.`}
|
{mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
||||||
>
|
</Text>
|
||||||
<View style={styles.caption}>
|
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
||||||
<Text variant="caption" style={styles.captionText}>
|
</View>
|
||||||
{mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
)}
|
||||||
</Text>
|
|
||||||
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ width, height }}>
|
<View style={{ width, height }}>
|
||||||
{mode === 'spectrum' ? (
|
{mode === 'spectrum' ? (
|
||||||
@@ -51,6 +66,25 @@ export function Visualizer({ width, height = CANVAS_HEIGHT }: { width: number; h
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!interactive) {
|
||||||
|
return (
|
||||||
|
<View pointerEvents="none" style={[styles.stageWrap, { width, height }]}>
|
||||||
|
{content}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={toggle}
|
||||||
|
style={[styles.wrap, { width }]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Visualizer showing ${mode}. Tap to switch.`}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,6 +93,9 @@ const styles = StyleSheet.create({
|
|||||||
wrap: {
|
wrap: {
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
},
|
},
|
||||||
|
stageWrap: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
caption: {
|
caption: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
Reference in New Issue
Block a user