massive UI/UX overhaul

This commit is contained in:
Boof2015
2026-07-03 23:56:34 -04:00
parent 9ce825b673
commit 61e6147d3e
71 changed files with 2806 additions and 953 deletions
+31 -1
View File
@@ -1,6 +1,10 @@
import { View, StyleSheet } from 'react-native';
import { Text } from './Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import type { Track } from '@/types/audio';
/** A single mono pill (e.g. "FLAC", "24-BIT", "48.0 kHz"). */
@@ -17,13 +21,18 @@ export function Badge({ label }: { label: string }) {
/**
* Format badge row for a track. Mirrors desktop `TrackList.tsx`:
* `format.toUpperCase()` and `${(sampleRate / 1000).toFixed(1)} kHz`.
*
* `variant="plain"` drops the pill chrome for muted middot-joined text — used in
* dense track lists where the pills read as too first-class next to the title.
*/
export function FormatBadges({
track,
wrap = true,
variant = 'pill',
}: {
track: Pick<Track, 'format' | 'bitDepth' | 'sampleRate'>;
wrap?: boolean;
variant?: 'pill' | 'plain';
}) {
const labels: string[] = [];
if (track.format) labels.push(track.format.toUpperCase());
@@ -32,6 +41,22 @@ export function FormatBadges({
if (labels.length === 0) return null;
if (variant === 'plain') {
// Compact so it hugs its content instead of filling the row: drop the "-BIT"
// and "kHz" words and fold depth/rate into "24/44.1".
const parts: string[] = [];
if (track.format) parts.push(track.format.toUpperCase());
const rate = track.sampleRate ? (track.sampleRate / 1000).toFixed(1) : null;
if (track.bitDepth && rate) parts.push(`${track.bitDepth}/${rate}`);
else if (rate) parts.push(rate);
else if (track.bitDepth) parts.push(`${track.bitDepth}-bit`);
return (
<Text variant="mono" style={styles.plain} numberOfLines={1}>
{parts.join(' · ')}
</Text>
);
}
return (
<View style={[styles.row, !wrap && styles.rowNoWrap]}>
{labels.map((label) => (
@@ -63,6 +88,11 @@ const styles = StyleSheet.create({
fontSize: 10,
letterSpacing: 0.5,
},
plain: {
color: colors.textTertiary,
fontSize: 10,
letterSpacing: 0.3,
},
});
export default FormatBadges;
+1 -1
View File
@@ -5,7 +5,7 @@ import {
type LayoutChangeEvent,
type StyleProp,
type TextStyle,
type ViewStyle,
type ViewStyle
} from 'react-native';
import type { TextLayoutEvent } from 'react-native/Libraries/Types/CoreEventTypes';
import Animated, {
+11 -2
View File
@@ -1,12 +1,21 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
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 { SpectrumCurve } from './SpectrumCurve';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { skipToNext, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
+6 -2
View File
@@ -1,11 +1,15 @@
import { useEffect, useMemo, useRef } from 'react';
import {
useEffect,
useMemo,
useRef
} from 'react';
import {
PaintStyle,
Skia,
SkiaPictureView,
StrokeCap,
StrokeJoin,
type SkPicture,
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
import { useScopeStore } from '@/scope/scopeStore';
+5 -1
View File
@@ -1,4 +1,8 @@
import { View, StyleSheet, type ViewProps } from 'react-native';
import {
StyleSheet,
View,
type ViewProps
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing } from '@/theme';
+11 -2
View File
@@ -1,7 +1,16 @@
import { useRef, useState } from 'react';
import { View, StyleSheet, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent
} from 'react-native';
import { Text } from './Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { formatDuration } from '@/lib/format';
const THUMB_SIZE = 12;
+152
View File
@@ -0,0 +1,152 @@
import { useEffect } from 'react';
import {
Pressable,
StyleSheet,
View,
type LayoutChangeEvent
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming
} from 'react-native-reanimated';
import {
colors,
fonts,
radius
} from '@/theme';
import { motion } from '@/theme/motion';
const THUMB_INSET = 3;
export interface Segment {
key: string;
label: string;
}
interface SegmentedControlProps {
segments: Segment[];
value: string;
onChange: (key: string) => void;
}
/**
* Equal-width segmented control on the TabBar "playhead" pattern: one glass
* track, a thumb that glides to the active segment, labels cross-fading to the
* accent via interpolateColor on Animated.Text. Spring-free per theme/motion.
*/
export function SegmentedControl({ segments, value, onChange }: SegmentedControlProps) {
const count = segments.length;
const activeIndex = Math.max(
0,
segments.findIndex((segment) => segment.key === value),
);
const trackWidth = useSharedValue(0);
const position = useSharedValue(activeIndex);
useEffect(() => {
position.value = withTiming(activeIndex, motion.snap);
}, [activeIndex, position]);
const thumbStyle = useAnimatedStyle(() => {
const segment = count > 0 ? (trackWidth.value - THUMB_INSET * 2) / count : 0;
return {
width: segment,
transform: [{ translateX: position.value * segment }],
};
});
const onTrackLayout = (e: LayoutChangeEvent) => {
trackWidth.value = e.nativeEvent.layout.width;
};
return (
<View style={styles.track} onLayout={onTrackLayout}>
<Animated.View style={[styles.thumb, thumbStyle]} pointerEvents="none" />
{segments.map((segment) => (
<SegmentButton
key={segment.key}
label={segment.label}
focused={segment.key === value}
onPress={() => onChange(segment.key)}
/>
))}
</View>
);
}
function SegmentButton({
label,
focused,
onPress,
}: {
label: string;
focused: boolean;
onPress: () => void;
}) {
// 0 = inactive, 1 = active; drives the label colour cross-fade.
const progress = useSharedValue(focused ? 1 : 0);
useEffect(() => {
progress.value = withTiming(focused ? 1 : 0, motion.quick);
}, [focused, progress]);
const labelStyle = useAnimatedStyle(() => ({
color: interpolateColor(
progress.value,
[0, 1],
[colors.textSecondary, colors.accentTextStrong],
),
}));
return (
<Pressable
style={({ pressed }) => [styles.segment, pressed && styles.segmentPressed]}
onPress={onPress}
accessibilityRole="tab"
accessibilityState={{ selected: focused }}
>
<Animated.Text style={[styles.label, labelStyle]} numberOfLines={1}>
{label}
</Animated.Text>
</Pressable>
);
}
const styles = StyleSheet.create({
track: {
flexDirection: 'row',
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
padding: THUMB_INSET,
},
thumb: {
position: 'absolute',
top: THUMB_INSET,
bottom: THUMB_INSET,
left: THUMB_INSET,
backgroundColor: colors.glassHighlight,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
},
segment: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
},
segmentPressed: {
opacity: 0.72,
},
label: {
fontSize: 12,
fontFamily: fonts.sans.medium,
},
});
export default SegmentedControl;
+6 -2
View File
@@ -1,4 +1,8 @@
import { useEffect, useMemo, useRef } from 'react';
import {
useEffect,
useMemo,
useRef
} from 'react';
import {
PaintStyle,
Skia,
@@ -6,7 +10,7 @@ import {
StrokeCap,
StrokeJoin,
TileMode,
type SkPicture,
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
import { colors } from '@/theme';
+11 -3
View File
@@ -1,12 +1,20 @@
import { useState, type ReactNode } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import {
StyleSheet,
View,
type LayoutChangeEvent
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
import {
Gesture,
GestureDetector,
type GestureType
} from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
withTiming
} from 'react-native-reanimated';
import { colors } from '@/theme';
import { motion } from '@/theme/motion';
+13 -3
View File
@@ -1,15 +1,25 @@
import { useEffect, useState } from 'react';
import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
import {
View,
Pressable,
StyleSheet,
type LayoutChangeEvent
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
withTiming
} from 'react-native-reanimated';
import { MiniPlayer } from './MiniPlayer';
import { colors, fonts, layout, spacing } from '@/theme';
import {
colors,
fonts,
layout,
spacing
} from '@/theme';
import { motion } from '@/theme/motion';
type IconName = keyof typeof Ionicons.glyphMap;
+10 -2
View File
@@ -1,6 +1,14 @@
import type { ReactNode } from 'react';
import { Text as RNText, type TextProps as RNTextProps, StyleSheet } from 'react-native';
import { colors, fonts, fontSize } from '@/theme';
import {
StyleSheet,
Text as RNText,
type TextProps as RNTextProps
} from 'react-native';
import {
colors,
fonts,
fontSize
} from '@/theme';
type Variant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono';
+5 -1
View File
@@ -1,5 +1,9 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
StyleSheet,
Pressable,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text';
import { SpectrumCurve } from './SpectrumCurve';
+19 -3
View File
@@ -1,6 +1,22 @@
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 {
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {
StyleSheet,
View,
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';
+24 -4
View File
@@ -1,11 +1,31 @@
import { Pressable, StyleSheet, Switch, View } from 'react-native';
import {
Pressable,
StyleSheet,
Switch,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import type { EQBand } from '@/types/audio';
import { EQ_MAX_FREQUENCY, EQ_MAX_GAIN_DB, EQ_MAX_Q, EQ_MIN_FREQUENCY, EQ_MIN_Q, isPassEQBandType } from '@/audio/eq';
import {
EQ_MAX_FREQUENCY,
EQ_MAX_GAIN_DB,
EQ_MAX_Q,
EQ_MIN_FREQUENCY,
EQ_MIN_Q,
isPassEQBandType
} from '@/audio/eq';
import { EQSlider } from './EQSlider';
import { BAND_TYPE_LABEL, formatFreq, formatGain } from './format';
import {
BAND_TYPE_LABEL,
formatFreq,
formatGain
} from './format';
interface BandDetailPanelProps {
band: EQBand | null;
+15 -3
View File
@@ -1,9 +1,21 @@
import { Pressable, ScrollView, StyleSheet } from 'react-native';
import {
Pressable,
ScrollView,
StyleSheet
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import type { EQBand } from '@/types/audio';
import { formatFreq, formatGain, gainColor } from './format';
import {
formatFreq,
formatGain,
gainColor
} from './format';
interface BandStripProps {
bands: EQBand[];
+8 -4
View File
@@ -1,9 +1,13 @@
import { useMemo, useRef, useState } from 'react';
import {
useMemo,
useRef,
useState
} from 'react';
import {
View,
StyleSheet,
type GestureResponderEvent,
type LayoutChangeEvent,
type LayoutChangeEvent
} from 'react-native';
import {
Canvas,
@@ -12,7 +16,7 @@ import {
Group,
Path,
Skia,
type SkPath,
type SkPath
} from '@shopify/react-native-skia';
import { Text } from '@/components/Text';
import { SpectrumCurve } from '@/components/SpectrumCurve';
@@ -25,7 +29,7 @@ import {
freqToX,
gainToY,
xToFreq,
yToGain,
yToGain
} from './eqGraphMath';
const HIT_RADIUS = 34;
+7 -48
View File
@@ -1,6 +1,4 @@
import { Pressable, StyleSheet, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { SegmentedControl } from '@/components/SegmentedControl';
import type { EQMode } from '@/types/audio';
const MODES: { key: EQMode; label: string }[] = [
@@ -8,7 +6,7 @@ const MODES: { key: EQMode; label: string }[] = [
{ key: 'graphic', label: 'Graphic' },
];
/** Two-segment Parametric | Graphic control (ViewModeSwitcher styling, fixed row). */
/** Two-segment Parametric | Graphic control (shared SegmentedControl). */
export function EQModeSwitcher({
value,
onChange,
@@ -17,51 +15,12 @@ export function EQModeSwitcher({
onChange: (mode: EQMode) => void;
}) {
return (
<View style={styles.row}>
{MODES.map((mode) => {
const active = mode.key === value;
return (
<Pressable
key={mode.key}
onPress={() => onChange(mode.key)}
style={[styles.pill, active && styles.pillActive]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text variant="label" style={[styles.label, active && styles.labelActive]}>
{mode.label}
</Text>
</Pressable>
);
})}
</View>
<SegmentedControl
segments={MODES}
value={value}
onChange={(key) => onChange(key as EQMode)}
/>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: spacing.sm,
},
pill: {
flex: 1,
alignItems: 'center',
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingVertical: spacing.xs + 2,
},
pillActive: {
borderColor: colors.accent,
backgroundColor: 'rgba(56, 189, 248, 0.08)',
},
label: {
color: colors.textSecondary,
},
labelActive: {
color: colors.accent,
},
});
export default EQModeSwitcher;
+6 -2
View File
@@ -4,10 +4,14 @@ import {
View,
StyleSheet,
type GestureResponderEvent,
type LayoutChangeEvent,
type LayoutChangeEvent
} from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
const THUMB = 16;
+7 -2
View File
@@ -3,11 +3,16 @@ import {
Pressable,
StyleSheet,
View,
type KeyboardTypeOptions,
type KeyboardTypeOptions
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, fonts, radius, spacing } from '@/theme';
import {
colors,
fonts,
radius,
spacing
} from '@/theme';
import { EqSheet } from './EqSheet';
interface EQValueEditSheetProps {
+5 -1
View File
@@ -5,7 +5,11 @@ import { EQ_MAX_GAIN_DB, EQ_MIN_GAIN_DB } from '@/audio/eq';
import { GRAPHIC_BANDS } from '@/audio/graphicEq';
import { GraphicResponseCurve } from './GraphicResponseCurve';
import { VerticalEQSlider } from './VerticalEQSlider';
import { formatFreqHz, formatGain, gainColor } from './format';
import {
formatFreqHz,
formatGain,
gainColor
} from './format';
interface GraphicEQPanelProps {
gains: number[];
+14 -3
View File
@@ -1,13 +1,24 @@
import { useMemo, useState } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import { Canvas, DashPathEffect, Group, Path, Skia, type SkPath } from '@shopify/react-native-skia';
import {
StyleSheet,
View,
type LayoutChangeEvent
} from 'react-native';
import {
Canvas,
DashPathEffect,
Group,
Path,
Skia,
type SkPath
} from '@shopify/react-native-skia';
import { colors } from '@/theme';
import type { EQBand } from '@/types/audio';
import {
EQ_MAX_FREQUENCY,
EQ_MAX_GAIN_DB,
EQ_MIN_FREQUENCY,
computeCombinedEQMagnitude,
computeCombinedEQMagnitude
} from '@/audio/eq';
import { GRAPHIC_BANDS, buildGraphicBands } from '@/audio/graphicEq';
import { GRAPH_SAMPLE_RATE, buildResponseFill } from './eqGraphMath';
+5 -1
View File
@@ -3,7 +3,11 @@ import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import type { EQPreset } from '@/types/audio';
import { EqSheet, EqSheetItem, EqSheetSection } from './EqSheet';
import {
EqSheet,
EqSheetItem,
EqSheetSection
} from './EqSheet';
interface PresetSheetProps {
presets: EQPreset[];
+11 -2
View File
@@ -1,8 +1,17 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, fonts, radius, spacing } from '@/theme';
import {
colors,
fonts,
radius,
spacing
} from '@/theme';
import { EqSheet } from './EqSheet';
interface SavePresetSheetProps {
+1 -1
View File
@@ -3,7 +3,7 @@ import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
type LayoutChangeEvent
} from 'react-native';
import { colors, radius } from '@/theme';
+12 -3
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library';
@@ -16,7 +24,8 @@ export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () =>
source={{ uri: artUri }}
style={styles.artImage}
contentFit="cover"
transition={120}
recyclingKey={album.identity_key}
transition={null}
/>
) : (
<AstraLogo size={36} />
+10 -2
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library';
+192
View File
@@ -0,0 +1,192 @@
/* eslint-disable react-hooks/immutability -- Reanimated shared values are mutable gesture state. */
import { useMemo, useState } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { tickHaptic } from '@/lib/haptics';
import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture';
import { RAIL_LETTERS } from '@/lib/letterIndex';
const CELL_HEIGHT = 17;
const RAIL_PAD = spacing.xs;
const RAIL_HEIGHT = RAIL_LETTERS.length * CELL_HEIGHT + RAIL_PAD * 2;
const BUBBLE_SIZE = 52;
interface AlphabetRailProps {
/** Letters present in the current list — the rest render dimmed. */
activeLetters: ReadonlySet<string>;
onJumpToLetter: (letter: string) => void;
}
/**
* A-Z scrubber overlaid on the right edge of a library list. Fixed cell
* geometry (full #A-Z always rendered) keeps the pointer math trivial; one
* haptic tick per letter crossed. The magnified letter bubble tracks the
* finger's vertical position (Y driven on the UI thread; the letter text only
* changes on a letter-cross). Blocks the pull-to-search gesture so a scrub at
* scroll-top never arms the search indicator.
*/
export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProps) {
const pullSearchRef = usePullSearchGestureRef();
const [scrubLetter, setScrubLetter] = useState<string | null>(null);
const lastLetter = useSharedValue('');
// Rail's top offset inside the (vertically-centered) wrap + the finger's Y
// within the rail, so the bubble can be placed in wrap-space.
const railTop = useSharedValue(0);
const bubbleY = useSharedValue(0);
const scrubTo = (letter: string) => {
setScrubLetter(letter);
onJumpToLetter(letter);
};
const endScrub = () => setScrubLetter(null);
const pan = useMemo(() => {
const gesture = Gesture.Pan()
.minDistance(0)
.onBegin((event) => {
'worklet';
lastLetter.value = '';
const y = Math.max(0, Math.min(RAIL_HEIGHT, event.y));
bubbleY.value = railTop.value + y;
const index = Math.max(
0,
Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT))
);
const letter = RAIL_LETTERS[index];
lastLetter.value = letter;
runOnJS(tickHaptic)();
runOnJS(scrubTo)(letter);
})
.onUpdate((event) => {
'worklet';
const y = Math.max(0, Math.min(RAIL_HEIGHT, event.y));
// Track the finger every frame for a smooth bubble; the letter/haptic
// below only fire when the letter actually changes.
bubbleY.value = railTop.value + y;
const index = Math.max(
0,
Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT))
);
const letter = RAIL_LETTERS[index];
if (letter === lastLetter.value) return;
lastLetter.value = letter;
runOnJS(tickHaptic)();
runOnJS(scrubTo)(letter);
})
.onFinalize(() => {
'worklet';
lastLetter.value = '';
runOnJS(endScrub)();
});
return pullSearchRef ? gesture.blocksExternalGesture(pullSearchRef) : gesture;
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrubTo/endScrub capture the latest onJumpToLetter via render closure
}, [lastLetter, bubbleY, railTop, pullSearchRef, onJumpToLetter]);
const bubbleStyle = useAnimatedStyle(() => ({
transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }],
}));
const onRailLayout = (e: LayoutChangeEvent) => {
railTop.value = e.nativeEvent.layout.y;
};
return (
<View style={styles.wrap} pointerEvents="box-none">
{scrubLetter ? (
<Animated.View style={[styles.bubble, bubbleStyle]} pointerEvents="none">
<Text variant="mono" style={styles.bubbleLetter}>
{scrubLetter}
</Text>
</Animated.View>
) : null}
<GestureDetector gesture={pan}>
<View style={styles.rail} hitSlop={{ left: 12, right: 8 }} onLayout={onRailLayout}>
{RAIL_LETTERS.map((letter) => {
const present = activeLetters.has(letter);
const scrubbing = letter === scrubLetter;
return (
<View key={letter} style={styles.cell}>
<Text
variant="mono"
style={[
styles.letter,
present ? styles.letterPresent : styles.letterAbsent,
scrubbing && styles.letterScrubbing,
]}
>
{letter}
</Text>
</View>
);
})}
</View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
position: 'absolute',
top: 0,
bottom: 0,
// Overhang the Screen's horizontal padding so the rail hugs the true edge.
right: -spacing.md,
justifyContent: 'center',
alignItems: 'flex-end',
},
// A faint scrim strip rather than a bordered glass pill: transparent enough to
// feel like an overlay, dark enough to keep the letters legible over bright art.
rail: {
width: 16,
paddingVertical: RAIL_PAD,
alignItems: 'center',
backgroundColor: 'rgba(8, 10, 15, 0.35)',
borderRadius: radius.pill,
},
cell: {
height: CELL_HEIGHT,
alignItems: 'center',
justifyContent: 'center',
},
letter: {
fontSize: 10,
lineHeight: CELL_HEIGHT,
},
letterPresent: {
color: colors.textSecondary,
},
letterAbsent: {
color: colors.textTertiary,
opacity: 0.4,
},
letterScrubbing: {
color: colors.accentTextStrong,
},
bubble: {
position: 'absolute',
top: 0,
right: 34,
width: BUBBLE_SIZE,
height: BUBBLE_SIZE,
borderRadius: radius.lg,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
elevation: 8,
shadowColor: '#000',
shadowOpacity: 0.28,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
},
bubbleLetter: {
fontSize: 26,
lineHeight: 30,
color: colors.accentTextStrong,
},
});
+90
View File
@@ -0,0 +1,90 @@
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import {
colors,
spacing,
radius
} from '@/theme';
import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library';
/** 2-column grid cell: square art (2x2 album mosaic when available) + counts, matching the album grid. */
export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: () => void }) {
const useMosaic = artist.artwork_hashes.length >= 4;
const hashes = useMosaic ? artist.artwork_hashes.slice(0, 4) : artist.artwork_hashes.slice(0, 1);
const albums = `${artist.album_count} ${artist.album_count === 1 ? 'album' : 'albums'}`;
const tracks = `${artist.track_count} ${artist.track_count === 1 ? 'track' : 'tracks'}`;
return (
<Pressable style={styles.item} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
{hashes.length === 0 ? (
<Ionicons name="person" size={44} color={colors.textTertiary} />
) : useMosaic ? (
hashes.map((hash) => (
<Image
key={hash}
source={{ uri: artworkUri(hash) }}
style={styles.mosaicTile}
contentFit="cover"
recyclingKey={hash}
transition={null}
/>
))
) : (
<Image
source={{ uri: artworkUri(hashes[0]) }}
style={styles.artImage}
contentFit="cover"
recyclingKey={hashes[0]}
transition={null}
/>
)}
</View>
<Text variant="body" numberOfLines={1} style={styles.name}>
{artist.artist}
</Text>
<Text variant="label" numberOfLines={1}>
{albums} · {tracks}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
item: {
flex: 1,
marginBottom: spacing.lg,
},
art: {
aspectRatio: 1,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginBottom: spacing.sm,
},
artImage: {
width: '100%',
height: '100%',
},
mosaicTile: {
width: '50%',
height: '50%',
},
name: {
fontSize: 14,
},
});
+10 -2
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library';
+459
View File
@@ -0,0 +1,459 @@
import {
useRef,
useState,
type ReactNode
} from 'react';
import {
Pressable,
StyleSheet,
View,
useWindowDimensions,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
useSharedValue,
type SharedValue
} from 'react-native-reanimated';
import {
Canvas,
LinearGradient,
Rect,
vec
} from '@shopify/react-native-skia';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
} from '@/theme';
// Collapsing detail header. An absolute container whose height shrinks with the
// scroll and clips its faded content, so the track list (padded to the expanded
// height) rises to meet it — no mid-scroll dead space. The artwork is a single
// element that shrinks/tucks into the top-left corner as the header collapses.
// Tune on device.
const ART_SIZE = 210;
const ART_COLLAPSED = 34;
const BAR_H = 48;
const ART_TOP = 44;
/** Top of the title/meta/buttons block, just below the artwork. */
const HERO_BLOCK_TOP = 262;
/** Gap below the buttons to the header's bottom edge (where row 1 sits at rest). */
const BLOCK_BOTTOM_PAD = 20;
/** Expanded header height below the inset until the block is measured. */
const FALLBACK_EXPANDED = 424;
const FADE_H = 150;
/**
* Scroll plumbing. Measures the hero block so the header height (and thus the
* collapse distance and the list's top padding) adapt to the title length —
* long titles don't clip the buttons. `heroFaded` disables the big (now-invisible)
* buttons before `collapsed` enables the header's icon buttons, so neither steals
* taps mid-transition.
*/
export function useDetailCollapse() {
const scrollY = useSharedValue(0);
const [expandedHeight, setExpandedHeight] = useState(FALLBACK_EXPANDED);
const expandedRef = useRef(FALLBACK_EXPANDED);
const ref = useRef({ heroFaded: false, collapsed: false });
const [state, setState] = useState({ heroFaded: false, collapsed: false });
const onHeroBlockLayout = (e: LayoutChangeEvent) => {
const next = HERO_BLOCK_TOP + e.nativeEvent.layout.height + BLOCK_BOTTOM_PAD;
if (Math.abs(next - expandedRef.current) > 1) {
expandedRef.current = next;
setExpandedHeight(next);
}
};
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = e.nativeEvent.contentOffset.y;
scrollY.value = y;
const dist = expandedRef.current - BAR_H;
const heroFaded = y >= 60;
const collapsed = y >= dist - 40;
if (heroFaded !== ref.current.heroFaded || collapsed !== ref.current.collapsed) {
ref.current = { heroFaded, collapsed };
setState({ heroFaded, collapsed });
}
};
return {
scrollY,
...state,
expandedHeight,
onHeroBlockLayout,
onScroll,
scrollEventThrottle: 16 as const,
};
}
function BottomFade() {
const [width, setWidth] = useState(0);
return (
<View style={styles.fade} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
{width > 0 ? (
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={width} height={FADE_H}>
<LinearGradient
start={vec(0, 0)}
end={vec(0, FADE_H)}
colors={[`${colors.bgPrimary}00`, colors.bgPrimary]}
/>
</Rect>
</Canvas>
) : null}
</View>
);
}
export function CollapsingHeader({
artwork,
backdropUri,
title,
heroMeta,
disabled,
onBack,
onPlay,
onShuffle,
scrollY,
heroFaded,
collapsed,
expandedHeight,
onHeroBlockLayout,
}: {
/** Fills the morphing art container (album cover, artist mosaic, or fallback). */
artwork: ReactNode;
backdropUri: string | null;
title: string;
/** The middle of the hero block, between title and buttons (subtitle/meta or stat chips). */
heroMeta: ReactNode;
disabled?: boolean;
onBack: () => void;
onPlay: () => void;
onShuffle: () => void;
scrollY: SharedValue<number>;
heroFaded: boolean;
collapsed: boolean;
/** Measured expanded height below the inset (from useDetailCollapse). */
expandedHeight: number;
onHeroBlockLayout: (e: LayoutChangeEvent) => void;
}) {
const insets = useSafeAreaInsets();
const { width: W } = useWindowDimensions();
const dist = expandedHeight - BAR_H;
const settle = dist - 36;
const maxH = insets.top + expandedHeight;
const minH = insets.top + BAR_H;
const barCenterY = insets.top + BAR_H / 2;
const artExpandedTop = insets.top + ART_TOP;
const thumbCenterX = spacing.md + 24 + spacing.sm + ART_COLLAPSED / 2;
const txTarget = thumbCenterX - W / 2;
const tyTarget = barCenterY - (artExpandedTop + ART_SIZE / 2);
const scaleTarget = ART_COLLAPSED / ART_SIZE;
const containerStyle = useAnimatedStyle(() => ({
height: interpolate(scrollY.value, [0, dist], [maxH, minH], Extrapolation.CLAMP),
}));
const artStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(scrollY.value, [dist * 0.4, settle], [0, txTarget], Extrapolation.CLAMP) },
{ translateY: interpolate(scrollY.value, [0, settle], [0, tyTarget], Extrapolation.CLAMP) },
{ scale: interpolate(scrollY.value, [30, settle], [1, scaleTarget], Extrapolation.CLAMP) },
],
}));
// Lift + shrink as it fades, so the block recedes into the header rather than
// being covered by the rising rows.
const heroBlockStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, 80], [1, 0], Extrapolation.CLAMP),
transform: [
{ translateY: interpolate(scrollY.value, [0, 95], [0, -30], Extrapolation.CLAMP) },
{ scale: interpolate(scrollY.value, [0, 95], [1, 0.97], Extrapolation.CLAMP) },
],
}));
// The buttons sit closest to the incoming rows — lift and fade them a touch
// ahead of the text for a light stagger.
const heroButtonsStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, 58], [1, 0], Extrapolation.CLAMP),
transform: [{ translateY: interpolate(scrollY.value, [0, 75], [0, -16], Extrapolation.CLAMP) }],
}));
const barBgStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [dist - 100, dist - 20], [0, 1], Extrapolation.CLAMP),
}));
const labelStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, 45], [1, 0], Extrapolation.CLAMP),
}));
const barTitleStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [settle - 30, settle + 10], [0, 1], Extrapolation.CLAMP),
}));
const barIconsStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [settle - 24, settle + 16], [0, 1], Extrapolation.CLAMP),
transform: [
{ scale: interpolate(scrollY.value, [settle - 24, settle + 16], [0.7, 1], Extrapolation.CLAMP) },
],
}));
return (
<Animated.View style={[styles.container, containerStyle]} pointerEvents="box-none">
{/* Blurred wash (fixed tall, clipped by the shrinking container) + fade at the bottom edge. */}
<View style={[styles.wash, { height: maxH }]} pointerEvents="none">
{backdropUri ? (
<Image source={{ uri: backdropUri }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} transition={null} />
) : (
<View style={[StyleSheet.absoluteFill, styles.washFallback]} />
)}
<View style={styles.scrim} />
</View>
<BottomFade />
<Animated.View style={[styles.barBg, { height: minH }, barBgStyle]} pointerEvents="none">
{backdropUri ? (
<>
<Image source={{ uri: backdropUri }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} transition={null} />
<View style={styles.barScrim} />
</>
) : (
<View style={styles.barSolid} />
)}
</Animated.View>
<Animated.View
style={[styles.heroBlock, { top: insets.top + HERO_BLOCK_TOP }, heroBlockStyle]}
pointerEvents={heroFaded ? 'none' : 'auto'}
onLayout={onHeroBlockLayout}
>
<Text variant="title" numberOfLines={2} adjustsFontSizeToFit minimumFontScale={0.72} style={styles.heroTitle}>
{title}
</Text>
{heroMeta}
<Animated.View style={[styles.actionRow, heroButtonsStyle]}>
<Pressable
style={[styles.actionButton, styles.primaryAction, disabled && styles.disabledAction]}
onPress={onPlay}
disabled={disabled}
accessibilityRole="button"
>
<Ionicons name="play" size={17} color={colors.bgPrimary} />
<Text variant="body" style={styles.primaryActionText}>
Play
</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.secondaryAction, disabled && styles.disabledAction]}
onPress={onShuffle}
disabled={disabled}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={17} color={colors.accent} />
<Text variant="body" color={colors.accent} style={styles.secondaryActionText}>
Shuffle
</Text>
</Pressable>
</Animated.View>
</Animated.View>
<Pressable
onPress={onBack}
hitSlop={8}
style={[styles.chevron, { top: barCenterY - 12, left: spacing.md }]}
accessibilityRole="button"
accessibilityLabel="Back"
>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Animated.Text style={[styles.label, { top: barCenterY - 10, left: spacing.md + 26 }, labelStyle]}>
Library
</Animated.Text>
<Animated.Text
numberOfLines={1}
style={[
styles.barTitle,
{ top: barCenterY - 12, left: thumbCenterX + ART_COLLAPSED / 2 + spacing.sm, right: 84 },
barTitleStyle,
]}
>
{title}
</Animated.Text>
<Animated.View
style={[styles.barIcons, { top: barCenterY - 16, right: spacing.md }, barIconsStyle]}
pointerEvents={collapsed ? 'auto' : 'none'}
>
<Pressable onPress={onPlay} disabled={disabled} hitSlop={6} style={styles.iconBtn}>
<Ionicons name="play" size={20} color={colors.accent} />
</Pressable>
<Pressable onPress={onShuffle} disabled={disabled} hitSlop={6} style={styles.iconBtn}>
<Ionicons name="shuffle" size={20} color={colors.accent} />
</Pressable>
</Animated.View>
{/* Rendered last so the large art sits on top of the header text until it tucks away. */}
<Animated.View
style={[
styles.art,
{ top: artExpandedTop, left: (W - ART_SIZE) / 2, width: ART_SIZE, height: ART_SIZE },
artStyle,
]}
pointerEvents="none"
>
{artwork}
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
overflow: 'hidden',
},
wash: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
washFallback: {
backgroundColor: colors.bgTertiary,
opacity: 0.55,
},
scrim: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.bgPrimary,
opacity: 0.5,
},
fade: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: FADE_H,
},
barBg: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
barScrim: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.bgSecondary,
opacity: 0.82,
},
barSolid: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.bgSecondary,
},
heroBlock: {
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
alignItems: 'center',
gap: spacing.xs,
},
heroTitle: {
maxWidth: '100%',
textAlign: 'center',
},
actionRow: {
width: '100%',
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.lg,
},
actionButton: {
flex: 1,
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
},
primaryAction: {
backgroundColor: colors.accent,
},
secondaryAction: {
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.glassBg,
},
disabledAction: {
opacity: 0.45,
},
primaryActionText: {
color: colors.bgPrimary,
fontWeight: '600',
},
secondaryActionText: {
fontWeight: '600',
},
chevron: {
position: 'absolute',
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
},
label: {
position: 'absolute',
color: colors.textSecondary,
fontSize: 15,
},
barTitle: {
position: 'absolute',
color: colors.textPrimary,
fontSize: 18,
fontWeight: '600',
},
barIcons: {
position: 'absolute',
flexDirection: 'row',
gap: spacing.xs,
},
iconBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
art: {
position: 'absolute',
borderRadius: radius.lg,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
},
});
+10 -2
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
StyleSheet,
View,
Pressable
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
export function EmptyLibrary() {
const router = useRouter();
+138 -6
View File
@@ -5,22 +5,37 @@ import {
View,
type GestureResponderEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type NativeSyntheticEvent
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { Text } from '@/components/Text';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import {
AppSheet,
AppSheetItem,
AppSheetTitle
} from '@/components/sheets/AppSheet';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import { playTracks } from '@/audio/playbackController';
import {
playTracks,
shuffleTracks,
enqueueTopMany,
enqueueEndMany
} from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import {
buildFolderTree,
flattenFolderTree,
type FlattenedFolderTreeRow,
type FolderTreeNode
} from '@/library/folderTree';
import { formatDuration } from '@/lib/format';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import type { DbTrack } from '@/types/library';
@@ -33,16 +48,32 @@ interface FoldersViewProps {
function FolderRow({
row,
onToggle,
onPlay,
onShuffle,
onOpenActions,
}: {
row: Extract<FlattenedFolderTreeRow, { type: 'folder' }>;
onToggle: (nodeId: string) => void;
onPlay: (node: FolderTreeNode) => void;
onShuffle: (node: FolderTreeNode) => void;
onOpenActions: (node: FolderTreeNode) => void;
}) {
const { node, depth, isExpanded } = row;
const play = (event: GestureResponderEvent) => {
event.stopPropagation();
onPlay(node);
};
const shuffle = (event: GestureResponderEvent) => {
event.stopPropagation();
onShuffle(node);
};
return (
<Pressable
style={styles.folderRow}
style={({ pressed }) => [styles.folderRow, pressed && styles.rowPressed]}
onPress={() => onToggle(node.id)}
onLongPress={() => onOpenActions(node)}
accessibilityRole="button"
accessibilityState={{ expanded: isExpanded }}
>
@@ -70,6 +101,24 @@ function FolderRow({
<Text variant="mono" style={styles.count}>
{node.totalTrackCount}
</Text>
<Pressable
style={({ pressed }) => [styles.folderButton, pressed && styles.folderButtonPressed]}
onPress={play}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={`Play ${node.name}`}
>
<Ionicons name="play" size={16} color={colors.accent} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.folderButton, pressed && styles.folderButtonPressed]}
onPress={shuffle}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={`Shuffle ${node.name}`}
>
<Ionicons name="shuffle" size={16} color={colors.textSecondary} />
</Pressable>
</Pressable>
);
}
@@ -95,7 +144,11 @@ function FolderTrackRow({
return (
<Pressable
style={[styles.trackRow, active && styles.trackRowActive]}
style={({ pressed }) => [
styles.trackRow,
active && styles.trackRowActive,
pressed && styles.rowPressed,
]}
onPress={playFolderTrack}
onLongPress={onOpenActions}
accessibilityRole="button"
@@ -132,10 +185,29 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(() => new Set());
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [actionFolder, setActionFolder] = useState<FolderTreeNode | null>(null);
const tree = useMemo(() => buildFolderTree(folders, tracks), [folders, tracks]);
const rows = useMemo(() => flattenFolderTree(tree, expandedNodeIds), [expandedNodeIds, tree]);
// Folder-level playback runs the whole subtree (subfolders included), in tree order.
const playFolder = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return;
void playTracks(node.subtreeTracks.map(dbTrackToTrack), 0);
};
const shuffleFolder = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return;
void shuffleTracks(node.subtreeTracks.map(dbTrackToTrack));
};
const playFolderNext = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return;
void enqueueTopMany(node.subtreeTracks.map(dbTrackToTrack));
};
const queueFolder = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return;
void enqueueEndMany(node.subtreeTracks.map(dbTrackToTrack));
};
const toggleFolder = (nodeId: string) => {
setExpandedNodeIds((current) => {
const next = new Set(current);
@@ -173,7 +245,13 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
contentContainerStyle={styles.listContent}
renderItem={({ item }) =>
item.type === 'folder' ? (
<FolderRow row={item} onToggle={toggleFolder} />
<FolderRow
row={item}
onToggle={toggleFolder}
onPlay={playFolder}
onShuffle={shuffleFolder}
onOpenActions={setActionFolder}
/>
) : (
<FolderTrackRow
row={item}
@@ -184,6 +262,46 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
{actionFolder ? (
<AppSheet onClose={() => setActionFolder(null)}>
<AppSheetTitle
title={actionFolder.name}
subtitle={`${actionFolder.totalTrackCount} ${actionFolder.totalTrackCount === 1 ? 'track' : 'tracks'}`}
/>
<AppSheetItem
label="Play"
icon="play"
onPress={() => {
playFolder(actionFolder);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Shuffle"
icon="shuffle"
onPress={() => {
shuffleFolder(actionFolder);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Play next"
icon="play-skip-forward"
onPress={() => {
playFolderNext(actionFolder);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Add to queue"
icon="list-outline"
onPress={() => {
queueFolder(actionFolder);
setActionFolder(null);
}}
/>
</AppSheet>
) : null}
</>
);
}
@@ -215,6 +333,17 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
fontSize: 12,
},
folderButton: {
width: 32,
height: 32,
flexShrink: 0,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
},
folderButtonPressed: {
backgroundColor: colors.glassBg,
},
trackRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -227,6 +356,9 @@ const styles = StyleSheet.create({
trackRowActive: {
backgroundColor: colors.accentGlow,
},
rowPressed: {
opacity: 0.72,
},
trackMeta: {
flex: 1,
minWidth: 0,
+10 -2
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { artworkUri } from '@/library/artwork';
export function PlaylistRow({
+36 -9
View File
@@ -5,17 +5,25 @@ import {
StyleSheet,
Alert,
type NativeScrollEvent,
type NativeSyntheticEvent,
type NativeSyntheticEvent
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import {
AppSheet,
AppSheetItem,
AppSheetTitle
} from '@/components/sheets/AppSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { Playlist } from '@/types/playlist';
@@ -164,6 +172,14 @@ export function PlaylistsView({
onLongPress={() => setMenuFor(item)}
/>
)}
ListEmptyComponent={
<View style={styles.empty}>
<Ionicons name="musical-notes-outline" size={28} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
No playlists yet. Create one or import an M3U below.
</Text>
</View>
}
ListFooterComponent={
<View style={styles.actions}>
<Pressable
@@ -190,12 +206,14 @@ export function PlaylistsView({
}
/>
<ActionSheet
visible={menuFor !== null}
title={menuFor === 'favorites' ? 'Favorites' : (menuFor?.name ?? '')}
items={menuItems}
onClose={() => setMenuFor(null)}
/>
{menuFor !== null ? (
<AppSheet onClose={() => setMenuFor(null)}>
<AppSheetTitle title={menuFor === 'favorites' ? 'Favorites' : menuFor.name} />
{menuItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
</AppSheet>
) : null}
<TextPromptModal
visible={prompt !== null}
title={prompt?.kind === 'rename' ? 'Rename playlist' : 'New playlist'}
@@ -225,6 +243,15 @@ const styles = StyleSheet.create({
gap: spacing.md,
marginTop: spacing.lg,
},
empty: {
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.xl,
},
emptyText: {
textAlign: 'center',
maxWidth: 260,
},
action: {
flexDirection: 'row',
alignItems: 'center',
+1 -1
View File
@@ -1,4 +1,4 @@
import { View, StyleSheet } from 'react-native';
import { StyleSheet, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
@@ -0,0 +1,109 @@
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
interface SelectionActionBarProps {
count: number;
onPlayNext: () => void;
onAddToQueue: () => void;
onAddToPlaylist: () => void;
}
/** Bottom batch-action bar for library multi-select (QueueTray action-bar language). */
export function SelectionActionBar({
count,
onPlayNext,
onAddToQueue,
onAddToPlaylist,
}: SelectionActionBarProps) {
const disabled = count === 0;
return (
<View style={styles.bar}>
<BarButton
icon="play-skip-forward"
label={`Play next (${count})`}
accessibilityLabel={`Play ${count} selected tracks next`}
disabled={disabled}
onPress={onPlayNext}
/>
<BarButton
icon="list-outline"
label={`Queue (${count})`}
accessibilityLabel={`Add ${count} selected tracks to the queue`}
disabled={disabled}
onPress={onAddToQueue}
/>
<BarButton
icon="add-circle-outline"
label={`Playlist (${count})`}
accessibilityLabel={`Add ${count} selected tracks to a playlist`}
disabled={disabled}
onPress={onAddToPlaylist}
/>
</View>
);
}
function BarButton({
icon,
label,
accessibilityLabel,
disabled,
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
accessibilityLabel: string;
disabled: boolean;
onPress: () => void;
}) {
return (
<Pressable
style={({ pressed }) => [
styles.button,
pressed && styles.buttonPressed,
disabled && styles.buttonDisabled,
]}
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
>
<Ionicons name={icon} size={18} color={colors.accent} />
<Text variant="label" style={styles.label} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.bgTertiary,
},
button: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingVertical: spacing.md,
},
buttonPressed: {
opacity: 0.7,
},
buttonDisabled: {
opacity: 0.4,
},
label: {
color: colors.accent,
},
});
+16 -148
View File
@@ -1,13 +1,16 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { AppSheet, AppSheetItem, AppSheetSection, type AppSheetItemProps } from '@/components/sheets/AppSheet';
import {
AppSheet,
AppSheetItem,
AppSheetSection,
AppSheetTitle,
type AppSheetItemProps,
} from '@/components/sheets/AppSheet';
import { PlaylistPickerSheet } from '@/components/sheets/PlaylistPickerSheet';
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
import { colors, fonts, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library';
@@ -40,33 +43,19 @@ function TrackActionsSheetInner({
extraItems = [],
}: TrackActionsSheetProps & { track: DbTrack }) {
const router = useRouter();
const [step, setStep] = useState<'menu' | 'pickPlaylist' | 'newPlaylist'>(initialStep);
const [playlistName, setPlaylistName] = useState('');
const [step, setStep] = useState<'menu' | 'pickPlaylist'>(initialStep);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const playlists = usePlaylistStore((s) => s.playlists);
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
const addTracksToPlaylist = usePlaylistStore((s) => s.addTracksToPlaylist);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const artistName =
groupingMode === 'fileTags' ? resolveStrictBrowseArtist(track) : resolveCanonicalBrowseArtist(track);
const trimmedPlaylistName = playlistName.trim();
const closeAndRun = (run: () => void) => {
onClose();
run();
};
const addToNewPlaylist = () => {
if (!trimmedPlaylistName) return;
void (async () => {
const playlist = await createPlaylist(trimmedPlaylistName);
await addTracksToPlaylist(playlist.id, [track]);
})();
onClose();
};
const menuItems: TrackActionSheetItem[] = [
{
key: 'play-next',
@@ -119,78 +108,20 @@ function TrackActionsSheetInner({
},
];
const pickItems: TrackActionSheetItem[] = [
...playlists.map((playlist) => ({
key: `playlist-${playlist.id}`,
label: playlist.name,
icon: 'musical-notes-outline' as const,
onPress: () => closeAndRun(() => void addTracksToPlaylist(playlist.id, [track])),
})),
{
key: 'new-playlist',
label: 'New playlist...',
icon: 'add',
onPress: () => setStep('newPlaylist'),
},
];
if (step === 'pickPlaylist') {
return (
<AppSheet onClose={onClose}>
<SheetTitle title="Add to playlist" subtitle={track.title} />
{initialStep === 'menu' ? (
<AppSheetItem label="Track actions" icon="arrow-back" onPress={() => setStep('menu')} />
) : null}
{playlists.length === 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
No playlists yet.
</Text>
) : null}
{pickItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
</AppSheet>
);
}
if (step === 'newPlaylist') {
return (
<AppSheet onClose={onClose}>
<SheetTitle title="New playlist" subtitle={track.title} />
<BottomSheetTextInput
value={playlistName}
onChangeText={setPlaylistName}
placeholder="Playlist name"
placeholderTextColor={colors.textTertiary}
style={styles.input}
autoFocus
returnKeyType="done"
onSubmitEditing={addToNewPlaylist}
selectionColor={colors.accent}
/>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={() => setStep('pickPlaylist')}>
<Text variant="label" color={colors.textSecondary}>
Back
</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.create, !trimmedPlaylistName && styles.createDisabled]}
disabled={!trimmedPlaylistName}
onPress={addToNewPlaylist}
>
<Text variant="label" color={colors.accentTextStrong}>
Create
</Text>
</Pressable>
</View>
</AppSheet>
<PlaylistPickerSheet
tracks={[track]}
subtitle={track.title}
onClose={onClose}
onBackToMenu={initialStep === 'menu' ? () => setStep('menu') : undefined}
/>
);
}
return (
<AppSheet onClose={onClose}>
<SheetTitle title={track.title} subtitle={track.artist} />
<AppSheetTitle title={track.title} subtitle={track.artist} />
{menuItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
@@ -205,66 +136,3 @@ function TrackActionsSheetInner({
</AppSheet>
);
}
function SheetTitle({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<View style={styles.titleBlock}>
<Text variant="heading" numberOfLines={1} style={styles.title}>
{title}
</Text>
{subtitle ? (
<Text variant="label" numberOfLines={1} color={colors.textSecondary}>
{subtitle}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
titleBlock: {
marginTop: spacing.xs,
marginBottom: spacing.sm,
gap: 2,
},
title: {
paddingRight: spacing.lg,
},
empty: {
paddingVertical: spacing.sm,
},
input: {
color: colors.textPrimary,
fontFamily: fonts.sans.regular,
fontSize: 16,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
create: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
createDisabled: {
opacity: 0.4,
},
});
+51 -10
View File
@@ -1,5 +1,10 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet, type GestureResponderEvent } from 'react-native';
import {
View,
Pressable,
StyleSheet,
type GestureResponderEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
@@ -7,7 +12,11 @@ import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { RemoteSourceBadge } from '@/components/RemoteSourceBadge';
import { SwipeableRow } from '@/components/SwipeableRow';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { formatDuration } from '@/lib/format';
import { trackArtworkThumbSource } from '@/library/artwork';
import { dbTrackToTrack } from '@/library/trackAdapter';
@@ -16,6 +25,7 @@ import type { DbTrack } from '@/types/library';
const ART_SIZE = 44;
const ROW_MIN_HEIGHT = ART_SIZE + (spacing.sm + 2) * 2;
const ACTIONS_BUTTON = 34;
export function TrackRow({
track,
@@ -26,6 +36,9 @@ export function TrackRow({
subtitle,
active = false,
swipeToQueue = true,
selectionMode = false,
selected = false,
onToggleSelect,
}: {
track: DbTrack;
onPress: () => void;
@@ -40,6 +53,10 @@ export function TrackRow({
active?: boolean;
/** Swipe right → play next, swipe left → add to queue. Off in queue-like lists. */
swipeToQueue?: boolean;
/** Multi-select: press toggles selection, checkbox leads, swipes/actions off. */
selectionMode?: boolean;
selected?: boolean;
onToggleSelect?: () => void;
}) {
// Key the artwork by hash (local) or identity path (remote) so the error fallback
// and FlashList recycling work for both.
@@ -55,11 +72,20 @@ export function TrackRow({
const row = (
<Pressable
style={styles.row}
onPress={onPress}
onLongPress={onLongPress ?? onOpenActions}
style={[styles.row, selectionMode && selected && styles.rowSelected]}
onPress={selectionMode ? onToggleSelect : onPress}
onLongPress={selectionMode ? onToggleSelect : (onLongPress ?? onOpenActions)}
accessibilityRole="button"
accessibilityState={selectionMode ? { selected } : undefined}
>
{selectionMode ? (
<Ionicons
name={selected ? 'checkmark-circle' : 'ellipse-outline'}
size={22}
color={selected ? colors.accent : colors.textTertiary}
style={styles.checkbox}
/>
) : null}
<View style={styles.art}>
{thumbUri ? (
<Image
@@ -99,6 +125,7 @@ export function TrackRow({
<View style={styles.badges}>
<RemoteSourceBadge sourceType={track.source_type} />
<FormatBadges
variant="plain"
track={{
format: track.format,
bitDepth: track.bit_depth ?? undefined,
@@ -108,11 +135,11 @@ export function TrackRow({
</View>
</View>
<Text variant="mono" style={styles.duration}>
<Text variant="mono" style={[styles.duration, selectionMode && styles.durationSelection]}>
{formatDuration(track.duration)}
</Text>
{onOpenActions ? (
{onOpenActions && !selectionMode ? (
<Pressable
style={({ pressed }) => [styles.actionsButton, pressed && styles.actionsButtonPressed]}
onPress={openActions}
@@ -126,7 +153,7 @@ export function TrackRow({
</Pressable>
);
if (!swipeToQueue) return row;
if (!swipeToQueue || selectionMode) return row;
return (
<SwipeableRow
@@ -159,6 +186,12 @@ const styles = StyleSheet.create({
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
rowSelected: {
backgroundColor: colors.glassHighlight,
},
checkbox: {
flexShrink: 0,
},
art: {
width: ART_SIZE,
height: ART_SIZE,
@@ -176,8 +209,11 @@ const styles = StyleSheet.create({
height: '100%',
},
trackNumber: {
width: 24,
width: 20,
flexShrink: 0,
// The row's uniform gap plus a right-aligned box leaves the number floating
// too far off the artwork; pull it back in toward the cover.
marginLeft: -spacing.sm,
fontSize: 12,
color: colors.textTertiary,
textAlign: 'right',
@@ -206,8 +242,13 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
textAlign: 'right',
},
// Selection mode drops the actions button; reserve its footprint so the
// duration holds its position instead of sliding to the row edge.
durationSelection: {
marginRight: ACTIONS_BUTTON + spacing.md,
},
actionsButton: {
width: 34,
width: ACTIONS_BUTTON,
height: 34,
flexShrink: 0,
borderRadius: radius.pill,
+6 -49
View File
@@ -1,6 +1,4 @@
import { Pressable, ScrollView, StyleSheet } from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { SegmentedControl } from '@/components/SegmentedControl';
export type LibraryViewMode = 'albums' | 'artists' | 'tracks' | 'playlists' | 'folders';
@@ -20,51 +18,10 @@ export function ViewModeSwitcher({
onChange: (mode: LibraryViewMode) => void;
}) {
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
{MODES.map((mode) => {
const active = mode.key === value;
return (
<Pressable
key={mode.key}
onPress={() => onChange(mode.key)}
style={[styles.pill, active && styles.pillActive]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text
variant="label"
style={[styles.label, active && styles.labelActive]}
>
{mode.label}
</Text>
</Pressable>
);
})}
</ScrollView>
<SegmentedControl
segments={MODES}
value={value}
onChange={(key) => onChange(key as LibraryViewMode)}
/>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: spacing.sm,
},
pill: {
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
},
pillActive: {
borderColor: colors.accent,
backgroundColor: 'rgba(56, 189, 248, 0.08)',
},
label: {
color: colors.textSecondary,
},
labelActive: {
color: colors.accent,
},
});
+16 -7
View File
@@ -4,22 +4,27 @@ import {
useEffect,
useMemo,
useRef,
useState,
useState
} from 'react';
import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
import {
Pressable,
StyleSheet,
View,
useWindowDimensions
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
useBottomSheetScrollableCreator,
useBottomSheetScrollableCreator
} from '@gorhom/bottom-sheet';
import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
import {
Gesture,
GestureDetector,
type GestureType,
type GestureType
} from 'react-native-gesture-handler';
import Animated, {
runOnJS,
@@ -27,13 +32,17 @@ import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
type SharedValue
} from 'react-native-reanimated';
import type { Track as RntpTrack } from 'react-native-track-player';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { SwipeableRow } from '@/components/SwipeableRow';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { motion } from '@/theme/motion';
import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
import { useQueueStore } from '@/stores/queueStore';
@@ -44,7 +53,7 @@ import {
removeManyFromQueue,
requeueManyToTop,
requeueToTop,
setUpcoming,
setUpcoming
} from '@/audio/playbackController';
import { useQueue } from './useQueue';
+21 -5
View File
@@ -8,7 +8,7 @@ import {
useRef,
useState,
type MutableRefObject,
type ReactNode,
type ReactNode
} from 'react';
import {
ScrollView as RNScrollView,
@@ -16,7 +16,7 @@ import {
View,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollViewProps,
type ScrollViewProps
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
@@ -24,11 +24,19 @@ import {
GestureDetector,
ScrollView as GestureScrollView,
type GestureType,
type NativeViewGestureHandlerProps,
type NativeViewGestureHandlerProps
} from 'react-native-gesture-handler';
import { runOnJS, runOnUI, useSharedValue } from 'react-native-reanimated';
import {
runOnJS,
runOnUI,
useSharedValue
} from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
const OPEN_THRESHOLD = 76;
@@ -86,6 +94,14 @@ export const PullSearchScrollView = forwardRef<RNScrollView, PullSearchScrollVie
}
);
/**
* The pull-to-search Pan gesture ref, so overlaid gestures (e.g. the A-Z rail)
* can declare relations like `.blocksExternalGesture(ref)` against it.
*/
export function usePullSearchGestureRef(): PullSearchGestureRef | null {
return useContext(PullSearchGestureContext)?.gestureRef ?? null;
}
export function useScrollTopGate(initialAtTop = true) {
const atTopRef = useRef(initialAtTop);
const [atTop, setAtTop] = useState(initialAtTop);
+24 -5
View File
@@ -1,4 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {
Keyboard,
Modal,
@@ -7,7 +12,7 @@ import {
TextInput,
View,
useWindowDimensions,
type GestureResponderEvent,
type GestureResponderEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
@@ -16,10 +21,20 @@ import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import {
colors,
fonts,
fontSize,
radius,
spacing
} from '@/theme';
import { enqueueTop, playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { albumArtworkSource, artworkUri, trackArtworkThumbSource } from '@/library/artwork';
import {
albumArtworkSource,
artworkUri,
trackArtworkThumbSource
} from '@/library/artwork';
import { multiFieldScore, MIN_SCORE_THRESHOLD } from '@/lib/fuzzySearch';
import { formatDuration } from '@/lib/format';
import { commitHaptic } from '@/lib/haptics';
@@ -27,7 +42,11 @@ import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
import type { Album, Artist, DbTrack } from '@/types/library';
import type {
Album,
Artist,
DbTrack
} from '@/types/library';
import type { Playlist } from '@/types/playlist';
type IconName = keyof typeof Ionicons.glyphMap;
+11 -2
View File
@@ -1,8 +1,17 @@
import { Modal, Pressable, StyleSheet, View } from 'react-native';
import {
Modal,
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
export interface ActionSheetItem {
key: string;
+34 -3
View File
@@ -1,14 +1,22 @@
import { useCallback, type ReactNode } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
StyleSheet,
Pressable,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetView,
type BottomSheetBackdropProps,
type BottomSheetBackdropProps
} from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
const insets = useSafeAreaInsets();
@@ -50,6 +58,21 @@ export function AppSheetSection({ label }: { label: string }) {
);
}
export function AppSheetTitle({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<View style={styles.titleBlock}>
<Text variant="heading" numberOfLines={1} style={styles.title}>
{title}
</Text>
{subtitle ? (
<Text variant="label" numberOfLines={1} color={colors.textSecondary}>
{subtitle}
</Text>
) : null}
</View>
);
}
export interface AppSheetItemProps {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
@@ -109,6 +132,14 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
marginBottom: spacing.xs,
},
titleBlock: {
marginTop: spacing.xs,
marginBottom: spacing.sm,
gap: 2,
},
title: {
paddingRight: spacing.lg,
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -0,0 +1,163 @@
import { useState } from 'react';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import {
AppSheet,
AppSheetItem,
AppSheetTitle
} from '@/components/sheets/AppSheet';
import {
colors,
fonts,
radius,
spacing
} from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { DbTrack } from '@/types/library';
interface PlaylistPickerSheetProps {
/** Tracks to add (in order) to the chosen or newly created playlist. */
tracks: DbTrack[];
/** Context line under the sheet title, e.g. a track title or "12 tracks". */
subtitle?: string;
onClose: () => void;
/** Renders a back item returning to the caller's own menu (track actions). */
onBackToMenu?: () => void;
/** Fires only when tracks were actually added (not on cancel/dismiss). */
onAdded?: () => void;
}
/** Two-step "add to playlist" sheet: pick an existing playlist or create one. */
export function PlaylistPickerSheet({
tracks,
subtitle,
onClose,
onBackToMenu,
onAdded,
}: PlaylistPickerSheetProps) {
const [step, setStep] = useState<'pick' | 'create'>('pick');
const [playlistName, setPlaylistName] = useState('');
const playlists = usePlaylistStore((s) => s.playlists);
const addTracksToPlaylist = usePlaylistStore((s) => s.addTracksToPlaylist);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const trimmedPlaylistName = playlistName.trim();
const addToExisting = (playlistId: number) => {
onClose();
void addTracksToPlaylist(playlistId, tracks);
onAdded?.();
};
const addToNewPlaylist = () => {
if (!trimmedPlaylistName) return;
void (async () => {
const playlist = await createPlaylist(trimmedPlaylistName);
await addTracksToPlaylist(playlist.id, tracks);
})();
onClose();
onAdded?.();
};
if (step === 'create') {
return (
<AppSheet onClose={onClose}>
<AppSheetTitle title="New playlist" subtitle={subtitle} />
<BottomSheetTextInput
value={playlistName}
onChangeText={setPlaylistName}
placeholder="Playlist name"
placeholderTextColor={colors.textTertiary}
style={styles.input}
autoFocus
returnKeyType="done"
onSubmitEditing={addToNewPlaylist}
selectionColor={colors.accent}
/>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={() => setStep('pick')}>
<Text variant="label" color={colors.textSecondary}>
Back
</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.create, !trimmedPlaylistName && styles.createDisabled]}
disabled={!trimmedPlaylistName}
onPress={addToNewPlaylist}
>
<Text variant="label" color={colors.accentTextStrong}>
Create
</Text>
</Pressable>
</View>
</AppSheet>
);
}
return (
<AppSheet onClose={onClose}>
<AppSheetTitle title="Add to playlist" subtitle={subtitle} />
{onBackToMenu ? (
<AppSheetItem label="Track actions" icon="arrow-back" onPress={onBackToMenu} />
) : null}
{playlists.length === 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
No playlists yet.
</Text>
) : null}
{playlists.map((playlist) => (
<AppSheetItem
key={playlist.id}
label={playlist.name}
icon="musical-notes-outline"
onPress={() => addToExisting(playlist.id)}
/>
))}
<AppSheetItem label="New playlist..." icon="add" onPress={() => setStep('create')} />
</AppSheet>
);
}
const styles = StyleSheet.create({
empty: {
paddingVertical: spacing.sm,
},
input: {
color: colors.textPrimary,
fontFamily: fonts.sans.regular,
fontSize: 16,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
create: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
createDisabled: {
opacity: 0.4,
},
});
+14 -2
View File
@@ -1,7 +1,19 @@
import { useState } from 'react';
import { Modal, Pressable, StyleSheet, TextInput, View } from 'react-native';
import {
Modal,
Pressable,
StyleSheet,
TextInput,
View
} from 'react-native';
import { Text } from '@/components/Text';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import {
colors,
fonts,
fontSize,
radius,
spacing
} from '@/theme';
interface TextPromptModalProps {
visible: boolean;