mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
m3, ui/ux, and more
This commit is contained in:
+14
-1
@@ -16,6 +16,7 @@ import {
|
||||
JetBrainsMono_500Medium,
|
||||
} from '@expo-google-fonts/jetbrains-mono';
|
||||
import { usePlaybackSync } from '@/audio/usePlaybackSync';
|
||||
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
@@ -27,6 +28,12 @@ function PlaybackSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Owns the visualizer on/off gate (foreground + playing + motion). Renders nothing. */
|
||||
function ScopeLifecycle() {
|
||||
useScopeLifecycle();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded] = useFonts({
|
||||
Inter_400Regular,
|
||||
@@ -59,6 +66,7 @@ export default function RootLayout() {
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="light" />
|
||||
<PlaybackSync />
|
||||
<ScopeLifecycle />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -68,7 +76,12 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen
|
||||
name="now-playing"
|
||||
options={{ presentation: 'modal', animation: 'slide_from_bottom' }}
|
||||
options={{
|
||||
presentation: 'transparentModal',
|
||||
animation: 'none',
|
||||
gestureEnabled: false,
|
||||
contentStyle: { backgroundColor: 'transparent' },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</SafeAreaProvider>
|
||||
|
||||
+242
-86
@@ -1,19 +1,42 @@
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } 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,
|
||||
} from 'react-native-reanimated';
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { FormatBadges } from '@/components/FormatBadge';
|
||||
import { SeekBar } from '@/components/SeekBar';
|
||||
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
||||
import { Visualizer } from '@/components/Visualizer';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { seekTo, skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
|
||||
|
||||
type IconName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
// Secondary controls are placeholders for now — laid out to settle the design.
|
||||
const SUB_CONTROLS: { icon: IconName; label: string }[] = [
|
||||
{ icon: 'shuffle', label: 'Shuffle' },
|
||||
{ icon: 'heart-outline', label: 'Favorite' },
|
||||
{ icon: 'list-outline', label: 'Queue' },
|
||||
{ icon: 'repeat', label: 'Repeat' },
|
||||
];
|
||||
|
||||
const DISMISS_DISTANCE = 140;
|
||||
const DISMISS_VELOCITY = 1000;
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
@@ -21,103 +44,215 @@ export default function NowPlayingScreen() {
|
||||
|
||||
const isPlaying = playbackState === 'playing';
|
||||
const isLoading = playbackState === 'loading';
|
||||
const contentWidth = windowWidth - spacing.xl * 2;
|
||||
const artSize = Math.min(296, contentWidth);
|
||||
const source = track?.album?.trim() ? track.album : 'Library';
|
||||
|
||||
// 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 dismiss = () => router.back();
|
||||
|
||||
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 = withSpring(0, { damping: 20, stiffness: 220 });
|
||||
}
|
||||
});
|
||||
|
||||
const contentStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.root,
|
||||
{ paddingTop: insets.top + spacing.sm, paddingBottom: insets.bottom + spacing.xl },
|
||||
]}
|
||||
>
|
||||
<Pressable style={styles.close} onPress={() => router.back()} hitSlop={12}>
|
||||
<Ionicons name="chevron-down" size={28} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
{track ? (
|
||||
<>
|
||||
<View style={styles.artWrap}>
|
||||
<View style={styles.art}>
|
||||
{track.artworkData ? (
|
||||
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
|
||||
) : (
|
||||
<AstraLogo size={104} />
|
||||
)}
|
||||
<View style={styles.backdrop}>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(240)}
|
||||
style={[
|
||||
styles.content,
|
||||
contentStyle,
|
||||
{ paddingTop: insets.top + spacing.sm, paddingBottom: insets.bottom + spacing.lg },
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
|
||||
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View style={styles.headerMid}>
|
||||
<Text variant="caption" style={styles.eyebrow}>
|
||||
PLAYING FROM
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1} style={styles.source}>
|
||||
{source}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable style={styles.headerBtn} hitSlop={12} accessibilityLabel="More options">
|
||||
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.meta}>
|
||||
<Text variant="heading" numberOfLines={2}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.textSecondary}
|
||||
numberOfLines={1}
|
||||
style={styles.subtitle}
|
||||
>
|
||||
{track.artist}
|
||||
{track.album ? ` · ${track.album}` : ''}
|
||||
</Text>
|
||||
<View style={styles.badges}>
|
||||
<FormatBadges track={track} />
|
||||
{track ? (
|
||||
<>
|
||||
<View style={styles.artWrap}>
|
||||
<View style={[styles.art, { width: artSize, height: artSize }]}>
|
||||
{track.artworkData ? (
|
||||
<Image
|
||||
source={{ uri: track.artworkData }}
|
||||
style={styles.artImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={Math.round(artSize * 0.4)} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Visualizer width={contentWidth} />
|
||||
|
||||
<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.progressBlock}>
|
||||
<WaveformSeekBar
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
trackKey={track.id}
|
||||
trackPath={track.path}
|
||||
onSeek={(seconds) => void seekTo(seconds)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
|
||||
<View style={styles.transport}>
|
||||
<Pressable onPress={skipToPrevious} hitSlop={12}>
|
||||
<Ionicons name="play-skip-back" size={32} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={togglePlay} hitSlop={12} style={styles.playButton}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={34}
|
||||
color={colors.bgPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={skipToNext} hitSlop={12}>
|
||||
<Ionicons name="play-skip-forward" size={32} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<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={20} 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>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBlock}>
|
||||
<SeekBar
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
trackKey={track.id}
|
||||
onSeek={(seconds) => void seekTo(seconds)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.transport}>
|
||||
<Pressable onPress={skipToPrevious} hitSlop={12}>
|
||||
<Ionicons name="play-skip-back" size={34} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={togglePlay} hitSlop={12} style={styles.playButton}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={36}
|
||||
color={colors.bgPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={skipToNext} hitSlop={12}>
|
||||
<Ionicons name="play-skip-forward" size={34} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.empty}>
|
||||
<Text variant="heading">Nothing playing</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.subtitle}>
|
||||
Start a track from Home.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
close: {
|
||||
alignSelf: 'flex-start',
|
||||
padding: spacing.xs,
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
artWrap: {
|
||||
flex: 1,
|
||||
headerBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerMid: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
eyebrow: {
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 1.5,
|
||||
fontSize: 10,
|
||||
},
|
||||
source: {
|
||||
color: colors.textSecondary,
|
||||
marginTop: 1,
|
||||
},
|
||||
artWrap: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
art: {
|
||||
width: 260,
|
||||
height: 260,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
alignItems: 'center',
|
||||
@@ -128,33 +263,54 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
meta: {
|
||||
marginTop: spacing.xl,
|
||||
trackInfo: {
|
||||
marginTop: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
centered: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
artist: {
|
||||
color: colors.accentText,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
badges: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
progressBlock: {
|
||||
marginTop: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
minHeight: spacing.md,
|
||||
},
|
||||
transport: {
|
||||
marginTop: spacing.xl,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xxl,
|
||||
},
|
||||
playButton: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
width: 68,
|
||||
height: 68,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
subRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
subBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user