mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
massive UI/UX overhaul
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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[];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
StyleSheet,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
type LayoutChangeEvent
|
||||
} from 'react-native';
|
||||
import { colors, radius } from '@/theme';
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user