mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +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',
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { useState } from 'react';
|
||||
import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Text } from './Text';
|
||||
import { AstraLogo } from './AstraLogo';
|
||||
import { colors, layout, radius, spacing } from '@/theme';
|
||||
import { SpectrumCurve } from './SpectrumCurve';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { togglePlay } from '@/audio/playbackController';
|
||||
import { skipToNext, togglePlay } from '@/audio/playbackController';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
|
||||
|
||||
const PILL_HEIGHT = 56;
|
||||
const ART = 42;
|
||||
const CURVE_POINTS = 64;
|
||||
|
||||
/**
|
||||
* Persistent mini-player, rendered above the tab bar. Tapping the bar opens the
|
||||
* full now-playing screen. The artwork box is where the spectrum "pulse"
|
||||
* is-playing indicator will live at M3.
|
||||
* Persistent floating mini-player (M3 redesign): a rounded pill above the tab
|
||||
* bar with the live filled-line spectrum drifting behind the metadata. Tapping
|
||||
* opens the full now-playing screen.
|
||||
*/
|
||||
export function MiniPlayer() {
|
||||
const router = useRouter();
|
||||
@@ -20,24 +28,38 @@ export function MiniPlayer() {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
|
||||
const scopeActive = useScopeActive();
|
||||
const values = useSpectrumCurve(CURVE_POINTS, scopeActive);
|
||||
const [pillWidth, setPillWidth] = useState(0);
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
const isPlaying = playbackState === 'playing';
|
||||
const isLoading = playbackState === 'loading';
|
||||
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
|
||||
</View>
|
||||
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
|
||||
|
||||
<Pressable style={styles.row} onPress={() => router.push('/now-playing')}>
|
||||
return (
|
||||
<Pressable style={styles.pill} onPress={() => router.push('/now-playing')} onLayout={onLayout}>
|
||||
{scopeActive && pillWidth > 0 && (
|
||||
<View pointerEvents="none" style={styles.spectrum}>
|
||||
<SpectrumCurve
|
||||
values={values}
|
||||
width={pillWidth}
|
||||
height={PILL_HEIGHT}
|
||||
lineWidth={1.5}
|
||||
fillOpacity={0.5}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.row}>
|
||||
<View style={styles.art}>
|
||||
{track.artworkData ? (
|
||||
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
|
||||
) : (
|
||||
<AstraLogo size={22} />
|
||||
<AstraLogo size={20} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -50,45 +72,56 @@ export function MiniPlayer() {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable hitSlop={12} onPress={togglePlay} style={styles.playButton}>
|
||||
<Pressable hitSlop={10} onPress={togglePlay} style={styles.control}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={26}
|
||||
size={24}
|
||||
color={colors.accent}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable hitSlop={10} onPress={skipToNext} style={styles.control}>
|
||||
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: layout.miniPlayerHeight,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
borderTopColor: colors.glassBorder,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
pill: {
|
||||
height: PILL_HEIGHT,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
borderColor: colors.glassBorder,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
progressTrack: {
|
||||
height: 2,
|
||||
backgroundColor: colors.glassBorder,
|
||||
},
|
||||
progressFill: {
|
||||
height: 2,
|
||||
backgroundColor: colors.accent,
|
||||
spectrum: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
art: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
width: ART,
|
||||
height: ART,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
@@ -103,12 +136,24 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 15,
|
||||
},
|
||||
playButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
control: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
progressTrack: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 2,
|
||||
backgroundColor: colors.glassBorder,
|
||||
},
|
||||
progressFill: {
|
||||
height: 2,
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
});
|
||||
|
||||
export default MiniPlayer;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Canvas, Group, LinearGradient, Path, Skia, vec } from '@shopify/react-native-skia';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
interface SpectrumCurveProps {
|
||||
/** Normalized magnitudes in [0,1], one per point (see useSpectrumCurve). */
|
||||
values: number[];
|
||||
width: number;
|
||||
height: number;
|
||||
/** Hex line/fill color (e.g. theme accent). Defaults to the cyan accent. */
|
||||
color?: string;
|
||||
lineWidth?: number;
|
||||
/** 0..1 multiplier on the gradient fill under the line. */
|
||||
fillOpacity?: number;
|
||||
/** Adds a soft wider stroke under the line for a glow. */
|
||||
glow?: boolean;
|
||||
}
|
||||
|
||||
/** #rrggbb -> rgba() with the given alpha. */
|
||||
function withAlpha(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a smooth (quadratic-through-midpoints) path for the line, plus a copy
|
||||
* closed to the baseline for the gradient fill. Same curve the desktop spectrum
|
||||
* draws, ported to Skia.
|
||||
*/
|
||||
function buildPaths(values: number[], width: number, height: number, pad: number) {
|
||||
const line = Skia.Path.Make();
|
||||
const n = values.length;
|
||||
if (n < 2 || width <= 0 || height <= 0) return { line, fill: line.copy() };
|
||||
|
||||
const usableH = height - pad * 2;
|
||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
||||
const yAt = (i: number) => {
|
||||
const v = values[i] < 0 ? 0 : values[i] > 1 ? 1 : values[i];
|
||||
return pad + (1 - v) * usableH;
|
||||
};
|
||||
|
||||
line.moveTo(xAt(0), yAt(0));
|
||||
for (let i = 1; i < n; i++) {
|
||||
const midX = (xAt(i - 1) + xAt(i)) * 0.5;
|
||||
const midY = (yAt(i - 1) + yAt(i)) * 0.5;
|
||||
line.quadTo(xAt(i - 1), yAt(i - 1), midX, midY);
|
||||
}
|
||||
line.lineTo(xAt(n - 1), yAt(n - 1));
|
||||
|
||||
const fill = line.copy();
|
||||
fill.lineTo(width, height);
|
||||
fill.lineTo(0, height);
|
||||
fill.close();
|
||||
|
||||
return { line, fill };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled-line spectrum (the desktop "CURVE" look): a smooth line over a vertical
|
||||
* gradient fill. Source-agnostic — give it normalized values and a size.
|
||||
*/
|
||||
export function SpectrumCurve({
|
||||
values,
|
||||
width,
|
||||
height,
|
||||
color = colors.accent,
|
||||
lineWidth = 2,
|
||||
fillOpacity = 1,
|
||||
glow = false,
|
||||
}: SpectrumCurveProps) {
|
||||
const pad = lineWidth;
|
||||
const { line, fill } = useMemo(
|
||||
() => buildPaths(values, width, height, pad),
|
||||
[values, width, height, pad]
|
||||
);
|
||||
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
|
||||
return (
|
||||
<Canvas style={{ width, height }}>
|
||||
<Group opacity={fillOpacity}>
|
||||
<Path path={fill}>
|
||||
<LinearGradient
|
||||
start={vec(0, 0)}
|
||||
end={vec(0, height)}
|
||||
colors={[withAlpha(color, 0.38), withAlpha(color, 0.08), withAlpha(color, 0)]}
|
||||
/>
|
||||
</Path>
|
||||
</Group>
|
||||
{glow && (
|
||||
<Path
|
||||
path={line}
|
||||
style="stroke"
|
||||
strokeWidth={lineWidth * 3}
|
||||
strokeJoin="round"
|
||||
strokeCap="round"
|
||||
color={withAlpha(color, 0.18)}
|
||||
/>
|
||||
)}
|
||||
<Path
|
||||
path={line}
|
||||
style="stroke"
|
||||
strokeWidth={lineWidth}
|
||||
strokeJoin="round"
|
||||
strokeCap="round"
|
||||
color={color}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpectrumCurve;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from './Text';
|
||||
import { SpectrumCurve } from './SpectrumCurve';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
|
||||
|
||||
const CANVAS_HEIGHT = 96;
|
||||
const POINTS = 120;
|
||||
|
||||
type Mode = 'spectrum' | 'scope';
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function Visualizer({ width }: { width: number }) {
|
||||
const [mode, setMode] = useState<Mode>('spectrum');
|
||||
const scopeActive = useScopeActive();
|
||||
const spectrumActive = scopeActive && mode === 'spectrum';
|
||||
const values = useSpectrumCurve(POINTS, spectrumActive);
|
||||
|
||||
const toggle = () => setMode((m) => (m === 'spectrum' ? 'scope' : 'spectrum'));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={toggle}
|
||||
style={[styles.wrap, { width }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Visualizer showing ${mode}. Tap to switch.`}
|
||||
>
|
||||
<View style={styles.caption}>
|
||||
<Text variant="caption" style={styles.captionText}>
|
||||
{mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
||||
</Text>
|
||||
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
||||
</View>
|
||||
|
||||
<View style={{ width, height: CANVAS_HEIGHT }}>
|
||||
{mode === 'spectrum' ? (
|
||||
<SpectrumCurve values={values} width={width} height={CANVAS_HEIGHT} glow />
|
||||
) : (
|
||||
<View style={styles.placeholder}>
|
||||
<Ionicons name="pulse-outline" size={20} color={colors.textTertiary} />
|
||||
<Text variant="caption" style={styles.placeholderText}>
|
||||
OSCILLOSCOPE · COMING SOON
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
caption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
captionText: {
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 1.5,
|
||||
fontSize: 10,
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
placeholderText: {
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 1.5,
|
||||
fontSize: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default Visualizer;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
|
||||
import { Canvas, Group, Path, Skia, rect } from '@shopify/react-native-skia';
|
||||
import { Text } from './Text';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { downsampleWaveform, getWaveform } from '@/scope/waveform';
|
||||
|
||||
const CANVAS_HEIGHT = 58;
|
||||
const BAR_WIDTH = 3;
|
||||
const BAR_GAP = 2;
|
||||
const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver
|
||||
// While a seek is pending, keep showing the target until the player's reported
|
||||
// position moves off the pre-seek value (`from`) — i.e. the seek has landed.
|
||||
const HOLD_EPS = 0.75;
|
||||
|
||||
interface WaveformSeekBarProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (seconds: number) => void;
|
||||
/** Identity of the playing track; a pending seek only applies to its own track. */
|
||||
trackKey?: string | number;
|
||||
/** Track file URI used to load/cache the offline waveform peaks. */
|
||||
trackPath?: string;
|
||||
}
|
||||
|
||||
const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
|
||||
|
||||
/**
|
||||
* Waveform seek bar (M3) — ports desktop WaveformSeekBar's look (RMS bars, a
|
||||
* played/unplayed split, draggable playhead) on Skia, while keeping SeekBar's
|
||||
* tap/drag + pending-seek "hold" state machine verbatim so seeking behaves
|
||||
* identically. Peaks load offline (getWaveform) and fall back to flat bars.
|
||||
*/
|
||||
export function WaveformSeekBar({
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
trackKey,
|
||||
trackPath,
|
||||
}: WaveformSeekBarProps) {
|
||||
const [scrubFraction, setScrubFraction] = useState<number | null>(null);
|
||||
const [barWidth, setBarWidth] = useState(0);
|
||||
const [pendingSeek, setPendingSeek] = useState<{
|
||||
target: number;
|
||||
from: number;
|
||||
key?: string | number;
|
||||
} | null>(null);
|
||||
// Peaks tagged with the path they belong to, so a track change drops the old
|
||||
// waveform as a pure derivation (no synchronous setState in the effect).
|
||||
const [loaded, setLoaded] = useState<{ path: string; peaks: Float32Array | null } | null>(null);
|
||||
|
||||
const widthRef = useRef(0);
|
||||
const scrubRef = useRef<number | null>(null);
|
||||
const grantRef = useRef({ fraction: 0, pageX: 0 });
|
||||
|
||||
// Load (cache-first) the offline peaks whenever the track changes.
|
||||
useEffect(() => {
|
||||
if (!trackPath) return;
|
||||
let cancelled = false;
|
||||
void getWaveform(trackPath).then((peaks) => {
|
||||
if (!cancelled) setLoaded({ path: trackPath, peaks });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trackPath]);
|
||||
|
||||
const source = loaded && loaded.path === trackPath ? loaded.peaks : null;
|
||||
|
||||
const setScrub = (fraction: number | null) => {
|
||||
scrubRef.current = fraction;
|
||||
setScrubFraction(fraction);
|
||||
};
|
||||
|
||||
const onLayout = (event: LayoutChangeEvent) => {
|
||||
widthRef.current = event.nativeEvent.layout.width;
|
||||
setBarWidth(event.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
const handleGrant = (event: GestureResponderEvent) => {
|
||||
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
|
||||
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
|
||||
setScrub(fraction);
|
||||
};
|
||||
|
||||
const handleMove = (event: GestureResponderEvent) => {
|
||||
const delta = (event.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current);
|
||||
setScrub(clamp(grantRef.current.fraction + delta));
|
||||
};
|
||||
|
||||
const handleRelease = () => {
|
||||
const fraction = scrubRef.current ?? grantRef.current.fraction;
|
||||
const target = fraction * duration;
|
||||
// Capture the pre-seek position so we can hold the target until the player
|
||||
// moves off it. Using `from` (not the target) means the hold releases when
|
||||
// the seek lands and can never re-engage as playback advances past target.
|
||||
setPendingSeek({ target, from: currentTime, key: trackKey });
|
||||
onSeek(target);
|
||||
setScrub(null);
|
||||
};
|
||||
|
||||
// Displayed position: scrub > held seek target > live progress. Hold while the
|
||||
// player still reports the stale pre-seek position; release once it jumps.
|
||||
const holdSeek =
|
||||
pendingSeek != null &&
|
||||
pendingSeek.key === trackKey &&
|
||||
duration > 0 &&
|
||||
Math.abs(currentTime - pendingSeek.from) < HOLD_EPS;
|
||||
const liveFraction = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
||||
const heldFraction = holdSeek ? clamp(pendingSeek.target / duration) : null;
|
||||
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
|
||||
const shownTime = fraction * duration;
|
||||
|
||||
const barCount = Math.max(1, Math.floor(barWidth / (BAR_WIDTH + BAR_GAP)));
|
||||
|
||||
// Build one Skia path of all bars (rounded rects). Drawn twice with a clip
|
||||
// split at the playhead: played in accent, unplayed in glassBorder.
|
||||
const barsPath = useMemo(() => {
|
||||
const path = Skia.Path.Make();
|
||||
if (barWidth <= 0) return path;
|
||||
const display = source
|
||||
? downsampleWaveform(source, barCount)
|
||||
: new Float32Array(barCount).fill(MIN_BAR);
|
||||
const r = BAR_WIDTH / 2;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const amp = Math.max(MIN_BAR, display[i] ?? MIN_BAR);
|
||||
const h = amp * CANVAS_HEIGHT;
|
||||
const x = i * (BAR_WIDTH + BAR_GAP);
|
||||
const y = (CANVAS_HEIGHT - h) / 2;
|
||||
path.addRRect(Skia.RRectXY(Skia.XYWHRect(x, y, BAR_WIDTH, h), r, r));
|
||||
}
|
||||
return path;
|
||||
}, [source, barCount, barWidth]);
|
||||
|
||||
const splitX = fraction * barWidth;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style={styles.touchArea}
|
||||
onLayout={onLayout}
|
||||
onStartShouldSetResponder={() => duration > 0}
|
||||
onMoveShouldSetResponder={() => duration > 0}
|
||||
onResponderTerminationRequest={() => false}
|
||||
onResponderGrant={handleGrant}
|
||||
onResponderMove={handleMove}
|
||||
onResponderRelease={handleRelease}
|
||||
onResponderTerminate={() => setScrub(null)}
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityLabel="Seek"
|
||||
accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }}
|
||||
>
|
||||
<Canvas style={{ width: '100%', height: CANVAS_HEIGHT }}>
|
||||
<Group clip={rect(0, 0, splitX, CANVAS_HEIGHT)}>
|
||||
<Path path={barsPath} color={colors.accent} />
|
||||
</Group>
|
||||
<Group clip={rect(splitX, 0, Math.max(0, barWidth - splitX), CANVAS_HEIGHT)}>
|
||||
<Path path={barsPath} color={colors.glassBorder} />
|
||||
</Group>
|
||||
</Canvas>
|
||||
</View>
|
||||
<View style={styles.times}>
|
||||
<Text variant="mono" style={[styles.time, scrubFraction != null && styles.timeActive]}>
|
||||
{formatDuration(shownTime)}
|
||||
</Text>
|
||||
<Text variant="mono" style={styles.time}>
|
||||
{formatDuration(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
touchArea: {
|
||||
justifyContent: 'center',
|
||||
height: CANVAS_HEIGHT + spacing.md * 2, // generous touch target around the canvas
|
||||
},
|
||||
times: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
time: {
|
||||
color: colors.textTertiary,
|
||||
fontSize: 13,
|
||||
},
|
||||
timeActive: {
|
||||
color: colors.accentText,
|
||||
},
|
||||
});
|
||||
|
||||
export default WaveformSeekBar;
|
||||
+27
-2
@@ -2,11 +2,13 @@
|
||||
// src/main/services/library.ts). v1 covers M1 (local scan + browse);
|
||||
// v2 adds playlists + favorites (M2); v3 forces re-extraction of tracks whose
|
||||
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts);
|
||||
// v4 adds a key-value settings table (artist grouping mode, future prefs).
|
||||
// v4 adds a key-value settings table (artist grouping mode, future prefs);
|
||||
// v5 caches offline waveform peaks for the M3 waveform seek bar; v6 repairs DBs
|
||||
// that an abandoned earlier M3 spike left at v5 with a stale `waveform_cache`.
|
||||
|
||||
import type { LibraryDatabase } from './database';
|
||||
|
||||
export const SCHEMA_VERSION = 4;
|
||||
export const SCHEMA_VERSION = 6;
|
||||
|
||||
// One statement per entry — op-sqlite executes single statements.
|
||||
const MIGRATIONS: readonly (readonly string[])[] = [
|
||||
@@ -90,6 +92,29 @@ const MIGRATIONS: readonly (readonly string[])[] = [
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
],
|
||||
// v4 -> v5 — cached waveform peaks (offline RMS bins) for the seek bar.
|
||||
// Keyed by track path (SAF URI), no FK — survives folder removal/re-grant
|
||||
// like favorites/playlists. `peaks` is a tightly-packed Float32 LE blob.
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS waveform_peaks (
|
||||
track_path TEXT PRIMARY KEY NOT NULL,
|
||||
bins INTEGER NOT NULL,
|
||||
peaks BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
],
|
||||
// v5 -> v6 — repair: an abandoned earlier M3 spike shipped a v5 that created a
|
||||
// different `waveform_cache` table, leaving such DBs at v5 without the
|
||||
// `waveform_peaks` table above. Create it if missing and drop the orphan.
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS waveform_peaks (
|
||||
track_path TEXT PRIMARY KEY NOT NULL,
|
||||
bins INTEGER NOT NULL,
|
||||
peaks BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
`DROP TABLE IF EXISTS waveform_cache`,
|
||||
],
|
||||
];
|
||||
|
||||
export async function migrate(db: LibraryDatabase): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Waveform peak cache — offline RMS bins for the M3 waveform seek bar.
|
||||
// Keyed by track path (SAF URI), mirroring favorites/playlists (no FK, so a row
|
||||
// survives folder removal and resolves again on re-grant). Peaks are normalized
|
||||
// to [0, 1] and stored as a tightly-packed Float32 little-endian blob.
|
||||
|
||||
import type { LibraryDatabase } from './database';
|
||||
|
||||
export async function getWaveformPeaks(
|
||||
db: LibraryDatabase,
|
||||
trackPath: string
|
||||
): Promise<Float32Array | null> {
|
||||
const row = await db.get<{ peaks: ArrayBuffer | ArrayBufferView }>(
|
||||
'SELECT peaks FROM waveform_peaks WHERE track_path = ?',
|
||||
[trackPath]
|
||||
);
|
||||
return row ? toFloat32(row.peaks) : null;
|
||||
}
|
||||
|
||||
export async function putWaveformPeaks(
|
||||
db: LibraryDatabase,
|
||||
trackPath: string,
|
||||
peaks: Float32Array
|
||||
): Promise<void> {
|
||||
// Bind the typed-array view directly (a valid ArrayBufferView Scalar); copy to
|
||||
// a tight view first if it's a window into a larger buffer.
|
||||
const tight =
|
||||
peaks.byteOffset === 0 && peaks.byteLength === peaks.buffer.byteLength
|
||||
? peaks
|
||||
: peaks.slice();
|
||||
await db.run(
|
||||
`INSERT INTO waveform_peaks (track_path, bins, peaks, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(track_path) DO UPDATE SET
|
||||
bins = excluded.bins, peaks = excluded.peaks, created_at = excluded.created_at`,
|
||||
[trackPath, peaks.length, tight, Date.now()]
|
||||
);
|
||||
}
|
||||
|
||||
function toFloat32(blob: ArrayBuffer | ArrayBufferView): Float32Array {
|
||||
if (blob instanceof Float32Array) return blob;
|
||||
if (ArrayBuffer.isView(blob)) {
|
||||
return new Float32Array(blob.buffer, blob.byteOffset, Math.floor(blob.byteLength / 4));
|
||||
}
|
||||
return new Float32Array(blob);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Whether the visualizers should run. Set by useScopeLifecycle (foreground +
|
||||
* playing + not reduced-motion) and read by the scope components so they only
|
||||
* spin their frame loop when something is actually visible and moving.
|
||||
*/
|
||||
interface ScopeStore {
|
||||
active: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export const useScopeStore = create<ScopeStore>((set) => ({
|
||||
active: false,
|
||||
setActive: (active) => set({ active }),
|
||||
}));
|
||||
|
||||
export const useScopeActive = (): boolean => useScopeStore((s) => s.active);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AccessibilityInfo, AppState } from 'react-native';
|
||||
import { AstraScope } from '../../modules/astra-scope';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useScopeStore } from './scopeStore';
|
||||
|
||||
/**
|
||||
* Single owner of the scope on/off gate. Visualizers run only when the app is
|
||||
* foregrounded, audio is playing, and reduced-motion is off — which also stops
|
||||
* the native PCM tap (AstraScope.setActive) so a backgrounded/paused app pays
|
||||
* ~nothing in the audio callback. Mount once near the root.
|
||||
*/
|
||||
export function useScopeLifecycle(): void {
|
||||
useEffect(() => {
|
||||
let reduceMotion = false;
|
||||
let appActive = AppState.currentState === 'active';
|
||||
|
||||
const recompute = () => {
|
||||
const playing = usePlayerStore.getState().playbackState === 'playing';
|
||||
const on = playing && appActive && !reduceMotion;
|
||||
AstraScope.setActive(on);
|
||||
useScopeStore.getState().setActive(on);
|
||||
};
|
||||
|
||||
const appSub = AppState.addEventListener('change', (state) => {
|
||||
appActive = state === 'active';
|
||||
recompute();
|
||||
});
|
||||
const rmSub = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => {
|
||||
reduceMotion = enabled;
|
||||
recompute();
|
||||
});
|
||||
const unsubPlayer = usePlayerStore.subscribe(recompute);
|
||||
void AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
|
||||
reduceMotion = enabled;
|
||||
recompute();
|
||||
});
|
||||
recompute();
|
||||
|
||||
return () => {
|
||||
appSub.remove();
|
||||
rmSub.remove();
|
||||
unsubPlayer();
|
||||
AstraScope.setActive(false);
|
||||
useScopeStore.getState().setActive(false);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
|
||||
|
||||
const FRAME_MS = 32; // ~30fps — ambient, battery-friendly
|
||||
|
||||
// Display window (dB). Tighter than the raw [-100,0] capture range so music
|
||||
// fills the curve with punch instead of hugging the floor.
|
||||
const DISPLAY_DB_MIN = -88;
|
||||
const DISPLAY_DB_MAX = -16;
|
||||
const DB_RANGE = DISPLAY_DB_MAX - DISPLAY_DB_MIN;
|
||||
|
||||
// Map points across a log-frequency (geometric bin) axis like the desktop
|
||||
// SpectrumAnalyzer, so the low end isn't squashed. Skip DC/rumble at the bottom.
|
||||
const BIN_LOW = 2;
|
||||
const BIN_HIGH = SPECTRUM_BINS - 1;
|
||||
// Gentle upward tilt (dB/octave) so the curve reads as a shape, not a downward
|
||||
// ramp dominated by bass — same idea as the desktop's spectrum tilt.
|
||||
const TILT_DB_PER_OCT = 2;
|
||||
|
||||
// Temporal smoothing: rise instantly, fall smoothly, for a fluid line.
|
||||
const RELEASE = 0.72;
|
||||
|
||||
// One reused buffer across all consumers: getSpectrumFrame fills it in place and
|
||||
// we read it out synchronously on the JS thread, so a module-level buffer is safe.
|
||||
const buffer = new Float32Array(SPECTRUM_BINS);
|
||||
|
||||
/**
|
||||
* Pulls the latest spectrum from the native tap on a JS-thread rAF loop (while
|
||||
* `active`) and returns `pointCount` magnitudes in [0,1] sampled on a
|
||||
* log-frequency axis, smoothed over time. Feeds the filled-line {@link
|
||||
* SpectrumCurve}. Returns all-zero (flat) points when inactive — no loop, no
|
||||
* setState — so callers render a clean baseline.
|
||||
*/
|
||||
export function useSpectrumCurve(pointCount: number, active: boolean): number[] {
|
||||
const [values, setValues] = useState<number[]>(() => new Array(pointCount).fill(0));
|
||||
const zeros = useMemo(() => new Array<number>(pointCount).fill(0), [pointCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return; // inactive: no loop, no setState; caller gets `zeros`
|
||||
let mounted = true;
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
|
||||
const smoothed = new Float32Array(pointCount);
|
||||
const logLow = Math.log(BIN_LOW);
|
||||
const logHigh = Math.log(BIN_HIGH);
|
||||
const binAt = (t: number) => Math.exp(logLow + t * (logHigh - logLow));
|
||||
const refBin = binAt(0.5); // tilt pivot (midband)
|
||||
|
||||
const tick = (t: number) => {
|
||||
if (!mounted) return;
|
||||
raf = requestAnimationFrame(tick);
|
||||
if (t - last < FRAME_MS) return;
|
||||
last = t;
|
||||
if (AstraScope.getSpectrumFrame(buffer) <= 0) return;
|
||||
|
||||
const out = new Array<number>(pointCount);
|
||||
for (let p = 0; p < pointCount; p++) {
|
||||
const b0 = binAt(p / pointCount);
|
||||
const b1 = binAt((p + 1) / pointCount);
|
||||
const lo = Math.max(BIN_LOW, Math.floor(b0));
|
||||
const hi = Math.min(BIN_HIGH, Math.max(lo, Math.ceil(b1)));
|
||||
|
||||
// Peak (loudest bin) across the band — punchier than an average.
|
||||
let db = -200;
|
||||
for (let i = lo; i <= hi; i++) if (buffer[i] > db) db = buffer[i];
|
||||
|
||||
const octaves = Math.log2(Math.max(1, (b0 + b1) * 0.5) / refBin);
|
||||
db += TILT_DB_PER_OCT * octaves;
|
||||
|
||||
let norm = (db - DISPLAY_DB_MIN) / DB_RANGE;
|
||||
if (norm < 0) norm = 0;
|
||||
else if (norm > 1) norm = 1;
|
||||
|
||||
const prev = smoothed[p];
|
||||
const next = norm >= prev ? norm : prev * RELEASE + norm * (1 - RELEASE);
|
||||
smoothed[p] = next;
|
||||
out[p] = next;
|
||||
}
|
||||
setValues(out);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
mounted = false;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [active, pointCount]);
|
||||
|
||||
return active ? values : zeros;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Waveform peaks for the seek bar: cache-first, decode-on-miss, store. The heavy
|
||||
// native decode (AstraLibraryScanner.extractWaveform) runs once per track and the
|
||||
// result is cached in SQLite; downsampleWaveform shapes the cached high-res peaks
|
||||
// to the display's bar count at render time (ported from desktop waveformExtractor).
|
||||
|
||||
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getWaveformPeaks, putWaveformPeaks } from '@/db/waveformQueries';
|
||||
|
||||
export const WAVEFORM_BINS = 512;
|
||||
|
||||
// Dedupe concurrent requests for the same track (e.g. mini-player + now-playing).
|
||||
const inflight = new Map<string, Promise<Float32Array | null>>();
|
||||
|
||||
export function getWaveform(trackPath: string): Promise<Float32Array | null> {
|
||||
const existing = inflight.get(trackPath);
|
||||
if (existing) return existing;
|
||||
const task = loadWaveform(trackPath).finally(() => inflight.delete(trackPath));
|
||||
inflight.set(trackPath, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function loadWaveform(trackPath: string): Promise<Float32Array | null> {
|
||||
const db = await openLibraryDb();
|
||||
const cached = await getWaveformPeaks(db, trackPath);
|
||||
if (cached && cached.length > 0) return cached;
|
||||
|
||||
let raw: number[];
|
||||
try {
|
||||
raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || raw.length === 0) return null;
|
||||
|
||||
const peaks = Float32Array.from(raw);
|
||||
await putWaveformPeaks(db, trackPath, peaks).catch(() => {
|
||||
/* cache write failure is non-fatal */
|
||||
});
|
||||
return peaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downsample high-res peaks to `barCount` bars with a power curve and two
|
||||
* smoothing passes. Ported verbatim from desktop waveformExtractor.ts so the
|
||||
* mobile seek bar matches the desktop look.
|
||||
*/
|
||||
export function downsampleWaveform(source: Float32Array, barCount: number): Float32Array {
|
||||
if (source.length === 0 || barCount <= 0) return new Float32Array(0);
|
||||
const binsPerBar = source.length / barCount;
|
||||
const peaks = new Float32Array(barCount);
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const start = Math.floor(i * binsPerBar);
|
||||
const end = Math.max(start + 1, Math.floor((i + 1) * binsPerBar));
|
||||
let sum = 0;
|
||||
for (let j = start; j < end; j++) sum += source[j];
|
||||
peaks[i] = sum / (end - start);
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
for (let i = 0; i < barCount; i++) if (peaks[i] > max) max = peaks[i];
|
||||
if (max > 0) for (let i = 0; i < barCount; i++) peaks[i] /= max;
|
||||
|
||||
// Power curve — exaggerate dynamic range.
|
||||
for (let i = 0; i < barCount; i++) peaks[i] = peaks[i] ** 2;
|
||||
|
||||
// Two smoothing passes.
|
||||
let current = peaks;
|
||||
for (let p = 0; p < 2; p++) {
|
||||
const smoothed = new Float32Array(current.length);
|
||||
smoothed[0] = current[0];
|
||||
smoothed[current.length - 1] = current[current.length - 1];
|
||||
for (let i = 1; i < current.length - 1; i++) {
|
||||
smoothed[i] = current[i - 1] * 0.25 + current[i] * 0.5 + current[i + 1] * 0.25;
|
||||
}
|
||||
current = smoothed;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
+21
-20
@@ -1,32 +1,33 @@
|
||||
/**
|
||||
* Astra color tokens — ported from desktop `src/renderer/styles/globals.css`.
|
||||
* Dark-only on mobile (the desktop app is dark-only too).
|
||||
* Astra color tokens. Dark-only. M3 redesign shifted the palette from
|
||||
* cyan-on-black toward a softer indigo-on-navy "mobile-first" language; these
|
||||
* tokens are the single source of truth, so a future theming pass can swap them.
|
||||
*/
|
||||
export const colors = {
|
||||
// Base backgrounds
|
||||
bgPrimary: '#000000',
|
||||
bgSecondary: '#050505',
|
||||
bgTertiary: '#0a0a0a',
|
||||
// Base backgrounds (navy)
|
||||
bgPrimary: '#080a0f',
|
||||
bgSecondary: '#0c0f18',
|
||||
bgTertiary: '#11162a',
|
||||
|
||||
// Glass / surface overlays (white alphas)
|
||||
glassBg: 'rgba(255, 255, 255, 0.03)',
|
||||
glassBorder: 'rgba(255, 255, 255, 0.08)',
|
||||
glassHighlight: 'rgba(255, 255, 255, 0.05)',
|
||||
// Glass / surface overlays (subtle blue-tinted alphas)
|
||||
glassBg: 'rgba(124, 146, 196, 0.05)',
|
||||
glassBorder: 'rgba(124, 146, 196, 0.16)',
|
||||
glassHighlight: 'rgba(140, 162, 208, 0.08)',
|
||||
|
||||
// Text (white alphas)
|
||||
textPrimary: 'rgba(255, 255, 255, 0.95)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||
textTertiary: 'rgba(255, 255, 255, 0.4)',
|
||||
// Text (blue-tinted neutrals)
|
||||
textPrimary: '#e2e8f4',
|
||||
textSecondary: '#8a98b8',
|
||||
textTertiary: '#52607f',
|
||||
|
||||
// Warning amber (desktop .graph-meta-chip-warning)
|
||||
warning: '#f3d27d',
|
||||
|
||||
// Cyan accent
|
||||
accent: '#38bdf8',
|
||||
accentHover: '#7dd3fc',
|
||||
accentGlow: 'rgba(56, 189, 248, 0.3)',
|
||||
accentText: '#bae6fd',
|
||||
accentTextStrong: '#e0f2fe',
|
||||
// Indigo accent
|
||||
accent: '#5b8aff',
|
||||
accentHover: '#82a6ff',
|
||||
accentGlow: 'rgba(91, 138, 255, 0.3)',
|
||||
accentText: '#a9c0ff',
|
||||
accentTextStrong: '#d6e2ff',
|
||||
|
||||
// Astra mark fills (hsl(198 …) from the desktop logo)
|
||||
logoMain: '#00b3ff', // hsl(198 100% 50%)
|
||||
|
||||
Reference in New Issue
Block a user