massive UI/UX overhaul

This commit is contained in:
Boof2015
2026-07-03 23:56:34 -04:00
parent 9ce825b673
commit 61e6147d3e
71 changed files with 2806 additions and 953 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"supportsTablet": true
},
"android": {
"package": "com.astra.mobile",
"package": "io.github.boof2015.astra",
"backgroundColor": "#000000",
"adaptiveIcon": {
"backgroundColor": "#000000",
+13 -3
View File
@@ -1,5 +1,11 @@
import { useCallback, useState } from 'react';
import { InteractionManager, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
import {
InteractionManager,
Pressable,
StyleSheet,
View,
useWindowDimensions
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -17,7 +23,11 @@ import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel';
import { PresetSheet } from '@/components/eq/PresetSheet';
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { isWideWindow } from '@/theme/adaptive';
import { useEQStore } from '@/stores/eqStore';
import { useScopeActive } from '@/scope/scopeStore';
@@ -31,7 +41,7 @@ import {
EQ_MIN_FREQUENCY,
EQ_MIN_PREAMP_DB,
EQ_MIN_Q,
isPassEQBandType,
isPassEQBandType
} from '@/audio/eq';
import { parseAutoEQ } from '@/audio/autoEQParser';
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
+15 -4
View File
@@ -1,5 +1,11 @@
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import {
Pressable,
ScrollView,
StyleSheet,
View,
type LayoutChangeEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
@@ -14,9 +20,14 @@ import { ScanProgress } from '@/components/library/ScanProgress';
import {
PullSearchGesture,
PullSearchScrollView,
useScrollTopGate,
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import { colors, fonts, radius, spacing } from '@/theme';
import {
colors,
fonts,
radius,
spacing
} from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -26,7 +37,7 @@ import {
shuffleTracks,
skipToNext,
skipToPrevious,
togglePlay,
togglePlay
} from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { albumArtworkSource } from '@/library/artwork';
+117 -128
View File
@@ -1,30 +1,48 @@
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, radius, spacing } from '@/theme';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { albumArtworkSource } from '@/library/artwork';
import { albumArtworkSource, artworkThumbUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
import type { DbTrack } from '@/types/library';
type AlbumRow =
| { kind: 'track'; track: DbTrack; index: number }
| { kind: 'disc'; disc: number };
function DiscHeader({ disc }: { disc: number }) {
return (
<View style={styles.discHeader}>
<Ionicons name="disc-outline" size={16} color={colors.textSecondary} />
<Text variant="heading">Disc {disc}</Text>
</View>
);
}
export default function AlbumScreen() {
const { key, from } = useLocalSearchParams<{ key: string; from?: string }>();
const albums = useLibraryStore((s) => s.albums);
const allTracks = useLibraryStore((s) => s.tracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const handleBack = useLibraryDetailBack(from);
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
const album = albums.find((entry) => entry.identity_key === key);
// Store tracks are ordered artist/album/disc/track, so the filtered slice
@@ -37,150 +55,121 @@ export default function AlbumScreen() {
const totalDuration = tracks.reduce((sum, track) => sum + track.duration, 0);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
// Interleave "Disc N" headers only when the album spans multiple discs;
// untagged tracks fall back to disc 1. Track rows keep their index into the
// flat `tracks` array so tap-to-play stays correct.
const albumItems = useMemo<AlbumRow[]>(() => {
const maxDisc = tracks.reduce((m, track) => Math.max(m, track.disc_number ?? 1), 1);
if (maxDisc <= 1) {
return tracks.map((track, index) => ({ kind: 'track', track, index }));
}
const rows: AlbumRow[] = [];
let lastDisc: number | null = null;
tracks.forEach((track, index) => {
const disc = track.disc_number ?? 1;
if (disc !== lastDisc) {
rows.push({ kind: 'disc', disc });
lastDisc = disc;
}
rows.push({ kind: 'track', track, index });
});
return rows;
}, [tracks]);
const playFrom = (index: number) => {
void playTracks(tracks.map(dbTrackToTrack), index);
};
const artSource = album ? albumArtworkSource(album) : null;
// Blur the cached thumbnail, not the full-res cover (remote albums have no
// local thumb — their server URL is already sized reasonably).
const backdropUri = album?.artwork_hash ? artworkThumbUri(album.artwork_hash) : artSource;
const meta = [
album?.year ? String(album.year) : null,
`${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ');
return (
<Screen>
<Pressable style={styles.back} onPress={handleBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Library
</Text>
</Pressable>
<View style={styles.header}>
<View style={styles.art}>
{album && albumArtworkSource(album) ? (
<Image
source={{ uri: albumArtworkSource(album)! }}
style={styles.artImage}
contentFit="cover"
transition={120}
/>
) : (
<AstraLogo size={42} />
)}
</View>
<View style={styles.headerMeta}>
<Text variant="heading" numberOfLines={2}>
{album?.album ?? 'Album'}
</Text>
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{album?.artist ?? ''}
</Text>
<Text variant="label">
{[
album?.year ? String(album.year) : null,
`${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ')}
</Text>
<View style={styles.buttons}>
<Pressable style={styles.playButton} onPress={() => playFrom(0)} accessibilityRole="button">
<Ionicons name="play" size={16} color={colors.bgPrimary} />
<Text variant="body" style={styles.playLabel}>
Play
</Text>
</Pressable>
<Pressable
style={styles.shuffleButton}
onPress={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
</View>
</View>
<Screen padded={false} style={styles.screen}>
<FlashList
data={tracks}
keyExtractor={(track) => String(track.id)}
data={albumItems}
keyExtractor={(item) => (item.kind === 'disc' ? `disc-${item.disc}` : String(item.track.id))}
getItemType={(item) => item.kind}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
showArtist={false}
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
onOpenActions={() => setActionTrack(item)}
/>
)}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: insets.top + expandedHeight,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxl,
}}
renderItem={({ item }) =>
item.kind === 'disc' ? (
<DiscHeader disc={item.disc} />
) : (
<TrackRow
track={item.track}
showArtist={false}
active={item.track.path === currentPath}
onPress={() => playFrom(item.index)}
onLongPress={() => setActionTrack(item.track)}
onOpenActions={() => setActionTrack(item.track)}
/>
)
}
/>
<CollapsingHeader
artwork={
artSource ? (
<Image source={{ uri: artSource }} style={styles.artFill} contentFit="cover" transition={150} />
) : (
<AstraLogo size={56} />
)
}
backdropUri={backdropUri}
title={album?.album ?? 'Album'}
heroMeta={
<>
{album?.artist ? (
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{album.artist}
</Text>
) : null}
<Text variant="label">{meta}</Text>
</>
}
disabled={tracks.length === 0}
onBack={handleBack}
onPlay={() => playFrom(0)}
onShuffle={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
scrollY={scrollY}
heroFaded={heroFaded}
collapsed={collapsed}
expandedHeight={expandedHeight}
onHeroBlockLayout={onHeroBlockLayout}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.md,
alignSelf: 'flex-start',
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
},
header: {
flexDirection: 'row',
gap: spacing.lg,
marginBottom: spacing.lg,
},
art: {
width: 128,
height: 128,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
artFill: {
width: '100%',
height: '100%',
},
headerMeta: {
flex: 1,
justifyContent: 'center',
gap: spacing.xs,
},
buttons: {
discHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
paddingTop: spacing.xl,
paddingBottom: spacing.md,
},
});
+89 -188
View File
@@ -1,22 +1,42 @@
import { useMemo, useState, type ComponentProps } from 'react';
import { Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native';
import {
useMemo,
useState,
type ComponentProps
} from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
View
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, fontSize, radius, spacing } from '@/theme';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import {
colors,
fontSize,
radius,
spacing
} from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { buildArtistDetail, type ArtistAlbum, type ArtistDetail } from '@/library/artistDetail';
import { artworkThumbUri, artworkUri } from '@/library/artwork';
import {
buildArtistDetail,
type ArtistAlbum,
type ArtistDetail
} from '@/library/artistDetail';
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
import type { DbTrack } from '@/types/library';
@@ -28,7 +48,6 @@ const ALBUM_PREVIEW_LIMIT = 8;
const APPEARANCE_PREVIEW_LIMIT = 5;
type ArtistPageItem =
| { key: 'hero'; type: 'hero' }
| {
key: string;
type: 'section';
@@ -44,7 +63,9 @@ export default function ArtistScreen() {
const router = useRouter();
const { name = 'Artist', from } = useLocalSearchParams<{ name: string; from?: string }>();
const handleBack = useLibraryDetailBack(from);
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
const allTracks = useLibraryStore((s) => s.tracks);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
@@ -77,16 +98,6 @@ export default function ArtistScreen() {
const renderItem = ({ item }: { item: ArtistPageItem }) => {
switch (item.type) {
case 'hero':
return (
<ArtistHero
artistName={name}
detail={detail}
compact={width < 380}
onPlay={playArtist}
onShuffle={shuffleArtist}
/>
);
case 'section': {
const target = item.target;
return (
@@ -135,30 +146,56 @@ export default function ArtistScreen() {
}
};
return (
<Screen>
<Pressable style={styles.back} onPress={handleBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Library
</Text>
</Pressable>
const backdropHash = detail.artworkHashes[0] ?? null;
const disabled = detail.playbackTracks.length === 0;
return (
<Screen padded={false} style={styles.screen}>
<FlashList
data={listItems}
keyExtractor={(item) => item.key}
showsVerticalScrollIndicator={false}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: insets.top + expandedHeight,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxl,
}}
/>
<CollapsingHeader
artwork={artistArtwork(detail.artworkHashes)}
backdropUri={backdropHash ? artworkThumbUri(backdropHash) : null}
title={name}
heroMeta={
<View style={styles.stats}>
{detail.albums.length > 0 ? (
<StatChip icon="albums-outline" label={formatCount(detail.albums.length, 'album')} />
) : null}
<StatChip icon="musical-notes-outline" label={formatCount(detail.tracks.length, 'track')} />
{detail.totalDuration > 0 ? (
<StatChip icon="time-outline" label={formatRuntime(detail.totalDuration)} />
) : null}
</View>
}
disabled={disabled}
onBack={handleBack}
onPlay={playArtist}
onShuffle={shuffleArtist}
scrollY={scrollY}
heroFaded={heroFaded}
collapsed={collapsed}
expandedHeight={expandedHeight}
onHeroBlockLayout={onHeroBlockLayout}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
function buildListItems(detail: ArtistDetail): ArtistPageItem[] {
const items: ArtistPageItem[] = [{ key: 'hero', type: 'hero' }];
const items: ArtistPageItem[] = [];
if (detail.tracks.length === 0) {
items.push({ key: 'empty', type: 'empty' });
@@ -209,83 +246,18 @@ function buildListItems(detail: ArtistDetail): ArtistPageItem[] {
return items;
}
function ArtistHero({
artistName,
detail,
compact,
onPlay,
onShuffle,
}: {
artistName: string;
detail: ArtistDetail;
compact: boolean;
onPlay: () => void;
onShuffle: () => void;
}) {
const disabled = detail.playbackTracks.length === 0;
return (
<View style={styles.hero}>
<ArtistArtwork hashes={detail.artworkHashes} compact={compact} />
<View style={styles.heroMeta}>
<Text
variant="title"
numberOfLines={3}
adjustsFontSizeToFit
minimumFontScale={0.78}
style={styles.artistName}
>
{artistName}
</Text>
<View style={styles.stats}>
{detail.albums.length > 0 ? (
<StatChip icon="albums-outline" label={formatCount(detail.albums.length, 'album')} />
) : null}
<StatChip icon="musical-notes-outline" label={formatCount(detail.tracks.length, 'track')} />
{detail.totalDuration > 0 ? (
<StatChip icon="time-outline" label={formatRuntime(detail.totalDuration)} />
) : null}
</View>
</View>
<View style={styles.actionRow}>
<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>
</View>
</View>
);
}
function ArtistArtwork({ hashes, compact }: { hashes: string[]; compact: boolean }) {
/** Inner artwork for the collapsing header: 2x2 album mosaic, single cover, or fallback. */
function artistArtwork(hashes: string[]) {
const useMosaic = hashes.length >= 4;
const displayHashes = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1);
const display = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1);
return (
<View style={[styles.heroArt, compact && styles.heroArtCompact]}>
{displayHashes.length === 0 ? (
<Ionicons name="person" size={compact ? 42 : 52} color={colors.textTertiary} />
) : useMosaic ? (
displayHashes.map((hash) => (
if (display.length === 0) {
return <Ionicons name="person" size={60} color={colors.textTertiary} />;
}
if (useMosaic) {
return (
<View style={styles.mosaic}>
{display.map((hash) => (
<Image
key={hash}
source={{ uri: artworkUri(hash) }}
@@ -293,16 +265,12 @@ function ArtistArtwork({ hashes, compact }: { hashes: string[]; compact: boolean
contentFit="cover"
transition={120}
/>
))
) : (
<Image
source={{ uri: artworkUri(displayHashes[0]) }}
style={styles.heroArtImage}
contentFit="cover"
transition={120}
/>
)}
</View>
))}
</View>
);
}
return (
<Image source={{ uri: artworkUri(display[0]) }} style={styles.artFill} contentFit="cover" transition={120} />
);
}
@@ -413,57 +381,23 @@ function trackSubtitle(track: DbTrack, section: 'appearances' | 'songs'): string
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.sm,
alignSelf: 'flex-start',
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
},
listContent: {
paddingBottom: spacing.xxl,
},
hero: {
alignItems: 'center',
gap: spacing.lg,
paddingTop: spacing.sm,
paddingBottom: spacing.lg,
},
heroArt: {
width: 168,
height: 168,
borderRadius: radius.lg,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
flexDirection: 'row',
flexWrap: 'wrap',
},
heroArtCompact: {
width: 144,
height: 144,
},
heroArtImage: {
mosaic: {
width: '100%',
height: '100%',
flexDirection: 'row',
flexWrap: 'wrap',
},
mosaicTile: {
width: '50%',
height: '50%',
},
heroMeta: {
artFill: {
width: '100%',
alignItems: 'center',
gap: spacing.md,
},
artistName: {
maxWidth: '100%',
textAlign: 'center',
lineHeight: fontSize.xxl * 1.08,
height: '100%',
},
stats: {
flexDirection: 'row',
@@ -487,39 +421,6 @@ const styles = StyleSheet.create({
statLabel: {
maxWidth: 180,
},
actionRow: {
width: '100%',
flexDirection: 'row',
gap: spacing.sm,
},
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',
},
sectionHeader: {
minHeight: 36,
flexDirection: 'row',
@@ -1,5 +1,9 @@
import { useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -1,5 +1,9 @@
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -1,5 +1,9 @@
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
+331 -97
View File
@@ -1,35 +1,78 @@
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import {
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {
BackHandler,
View,
Pressable,
StyleSheet
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { FlashList, type FlashListRef } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { ArtistGridItem } from '@/components/library/ArtistGridItem';
import { TrackRow } from '@/components/library/TrackRow';
import { ArtistRow } from '@/components/library/ArtistRow';
import { FoldersView } from '@/components/library/FoldersView';
import { PlaylistsView } from '@/components/library/PlaylistsView';
import { ScanProgress } from '@/components/library/ScanProgress';
import { EmptyLibrary } from '@/components/library/EmptyLibrary';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import { AlphabetRail } from '@/components/library/AlphabetRail';
import { SelectionActionBar } from '@/components/library/SelectionActionBar';
import {
AppSheet,
AppSheetItem,
AppSheetSection
} from '@/components/sheets/AppSheet';
import { PlaylistPickerSheet } from '@/components/sheets/PlaylistPickerSheet';
import {
PullSearchGesture,
PullSearchScrollView,
useScrollTopGate,
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
import { playTracks } from '@/audio/playbackController';
import {
enqueueEndMany,
enqueueTopMany,
playTracks
} from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { sortTracks, TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
import type { DbTrack } from '@/types/library';
import { commitHaptic, dragArmHaptic } from '@/lib/haptics';
import {
sortTracks,
TRACK_SORT_LABELS,
type TrackSort
} from '@/lib/trackSort';
import {
sortAlbums,
ALBUM_SORT_LABELS,
type AlbumSort
} from '@/lib/albumSort';
import {
sortArtists,
ARTIST_SORT_LABELS,
type ArtistSort
} from '@/lib/artistSort';
import { buildLetterIndex, resolveJumpIndex } from '@/lib/letterIndex';
import type {
Album,
Artist,
DbTrack
} from '@/types/library';
const SORT_OPTIONS: TrackSort[] = ['artist', 'title', 'recently_added', 'duration'];
const TRACK_SORT_OPTIONS: TrackSort[] = ['artist', 'title', 'recently_added', 'duration'];
const ALBUM_SORT_OPTIONS: AlbumSort[] = ['artist', 'name', 'recently_added', 'year'];
const ARTIST_SORT_OPTIONS: ArtistSort[] = ['name', 'track_count'];
export default function LibraryScreen() {
const router = useRouter();
@@ -41,6 +84,10 @@ export default function LibraryScreen() {
const folders = useLibraryStore((s) => s.folders);
const trackSort = useLibraryStore((s) => s.trackSort);
const setTrackSort = useLibraryStore((s) => s.setTrackSort);
const albumSort = useLibraryStore((s) => s.albumSort);
const setAlbumSort = useLibraryStore((s) => s.setAlbumSort);
const artistSort = useLibraryStore((s) => s.artistSort);
const setArtistSort = useLibraryStore((s) => s.setArtistSort);
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
@@ -48,14 +95,29 @@ export default function LibraryScreen() {
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const scrollTop = useScrollTopGate();
const tracksListRef = useRef<FlashListRef<DbTrack>>(null);
const albumsListRef = useRef<FlashListRef<Album>>(null);
const artistsListRef = useRef<FlashListRef<Artist>>(null);
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning;
const sortedTracks = useMemo(
() => (viewMode === 'tracks' ? sortTracks(tracks, trackSort) : []),
[trackSort, tracks, viewMode]
);
const sortedAlbums = useMemo(
() => (viewMode === 'albums' ? sortAlbums(albums, albumSort) : []),
[albumSort, albums, viewMode]
);
const sortedArtists = useMemo(
() => (viewMode === 'artists' ? sortArtists(artists, artistSort) : []),
[artistSort, artists, viewMode]
);
// Tap index is within sortedTracks so the tapped row is the track that plays.
const playAllFrom = (index: number) => {
@@ -63,6 +125,115 @@ export default function LibraryScreen() {
};
const openSearch = () => openQuickSearch();
// A-Z rail: only for sorts where a letter jump is meaningful.
const letterIndex = useMemo(() => {
if (viewMode === 'tracks' && (trackSort === 'artist' || trackSort === 'title')) {
return buildLetterIndex(sortedTracks, (t) => (trackSort === 'title' ? t.title : t.artist));
}
if (viewMode === 'albums' && (albumSort === 'artist' || albumSort === 'name')) {
return buildLetterIndex(sortedAlbums, (a) => (albumSort === 'name' ? a.album : a.artist));
}
if (viewMode === 'artists' && artistSort === 'name') {
return buildLetterIndex(sortedArtists, (a) => a.artist);
}
return [];
}, [albumSort, artistSort, sortedAlbums, sortedArtists, sortedTracks, trackSort, viewMode]);
const railVisible = letterIndex.length > 1;
const railLetters = useMemo(
() => new Set(letterIndex.map((entry) => entry.letter)),
[letterIndex]
);
const jumpToLetter = (letter: string) => {
const index = resolveJumpIndex(letterIndex, letter);
if (index == null) return;
// Fire-and-forget: letter-change granularity already throttles the calls.
if (viewMode === 'tracks') void tracksListRef.current?.scrollToIndex({ index, animated: false });
else if (viewMode === 'albums') void albumsListRef.current?.scrollToIndex({ index, animated: false });
else if (viewMode === 'artists') void artistsListRef.current?.scrollToIndex({ index, animated: false });
};
// Multi-select (tracks view): long-press arms it, batch actions live in the
// bottom bar, selection order follows the current display order.
const enterSelection = (track: DbTrack) => {
dragArmHaptic();
setSelectMode(true);
setSelectedIds(new Set([track.id]));
};
const toggleSelected = (id: number) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const exitSelection = () => {
setSelectMode(false);
setSelectedIds(new Set());
setPlaylistPickerOpen(false);
};
useEffect(() => {
if (!selectMode) return;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
exitSelection();
return true;
});
return () => sub.remove();
}, [selectMode]);
const selectedDbTracks = () => sortedTracks.filter((track) => selectedIds.has(track.id));
const batchPlayNext = () => {
const tracks = selectedDbTracks().map(dbTrackToTrack);
commitHaptic();
exitSelection();
void enqueueTopMany(tracks);
};
const batchAddToQueue = () => {
const tracks = selectedDbTracks().map(dbTrackToTrack);
commitHaptic();
exitSelection();
void enqueueEndMany(tracks);
};
// One sort trigger + sheet across the three sortable views.
const sortable = viewMode === 'tracks' || viewMode === 'albums' || viewMode === 'artists';
const sortLabel =
viewMode === 'tracks'
? TRACK_SORT_LABELS[trackSort]
: viewMode === 'albums'
? ALBUM_SORT_LABELS[albumSort]
: ARTIST_SORT_LABELS[artistSort];
const sortSheetLabel =
viewMode === 'tracks' ? 'SORT TRACKS BY' : viewMode === 'albums' ? 'SORT ALBUMS BY' : 'SORT ARTISTS BY';
const sortItems =
viewMode === 'tracks'
? TRACK_SORT_OPTIONS.map((option) => ({
key: option,
label: TRACK_SORT_LABELS[option],
selected: option === trackSort,
onSelect: () => setTrackSort(option),
}))
: viewMode === 'albums'
? ALBUM_SORT_OPTIONS.map((option) => ({
key: option,
label: ALBUM_SORT_LABELS[option],
selected: option === albumSort,
onSelect: () => setAlbumSort(option),
}))
: ARTIST_SORT_OPTIONS.map((option) => ({
key: option,
label: ARTIST_SORT_LABELS[option],
selected: option === artistSort,
onSelect: () => setArtistSort(option),
}));
return (
<Screen>
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
@@ -90,6 +261,7 @@ export default function LibraryScreen() {
<ViewModeSwitcher
value={viewMode}
onChange={(mode) => {
if (selectMode) exitSelection();
scrollTop.setScrollAtTop(true);
setViewMode(mode);
}}
@@ -102,66 +274,87 @@ export default function LibraryScreen() {
</Text>
) : null}
{viewMode === 'albums' ? (
<FlashList
data={albums}
numColumns={2}
keyExtractor={(album) => album.identity_key}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<View style={styles.gridCell}>
<AlbumGridItem
album={item}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.identity_key },
})
}
/>
</View>
)}
/>
) : null}
{viewMode === 'artists' ? (
<FlashList
data={artists}
keyExtractor={(artist) => artist.artist}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<ArtistRow
artist={item}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist },
})
}
/>
)}
/>
) : null}
{viewMode === 'tracks' ? (
<>
<Pressable
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
accessibilityRole="button"
>
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
<Text variant="label">{TRACK_SORT_LABELS[trackSort]}</Text>
{selectMode ? (
<View style={styles.selectionHeader}>
<Text variant="label">
{selectedIds.size} selected
</Text>
<Pressable onPress={exitSelection} hitSlop={8} accessibilityRole="button">
<Text variant="label" color={colors.accentText}>
Cancel
</Text>
</Pressable>
</View>
) : sortable ? (
<Pressable
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
accessibilityRole="button"
accessibilityLabel={`Sort by ${sortLabel}`}
>
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
<Text variant="label">{sortLabel}</Text>
</Pressable>
) : null}
<View style={styles.listArea}>
{viewMode === 'albums' ? (
<FlashList
ref={albumsListRef}
data={sortedAlbums}
numColumns={3}
keyExtractor={(album) => album.identity_key}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<View style={styles.gridCell}>
<AlbumGridItem
album={item}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.identity_key },
})
}
/>
</View>
)}
/>
) : null}
{viewMode === 'artists' ? (
<FlashList
ref={artistsListRef}
data={sortedArtists}
numColumns={3}
keyExtractor={(artist) => artist.artist}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<View style={styles.gridCell}>
<ArtistGridItem
artist={item}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist },
})
}
/>
</View>
)}
/>
) : null}
{viewMode === 'tracks' ? (
<FlashList
ref={tracksListRef}
data={sortedTracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
@@ -169,51 +362,81 @@ export default function LibraryScreen() {
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
extraData={selectMode ? selectedIds : undefined}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
onLongPress={() => setActionTrack(item)}
onLongPress={() => enterSelection(item)}
onOpenActions={() => setActionTrack(item)}
selectionMode={selectMode}
selected={selectedIds.has(item.id)}
onToggleSelect={() => toggleSelected(item.id)}
/>
)}
/>
</>
) : null}
) : null}
{viewMode === 'playlists' ? (
<PlaylistsView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{viewMode === 'playlists' ? (
<PlaylistsView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{viewMode === 'folders' ? (
<FoldersView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{viewMode === 'folders' ? (
<FoldersView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{railVisible ? (
<AlphabetRail activeLetters={railLetters} onJumpToLetter={jumpToLetter} />
) : null}
</View>
</>
)}
</PullSearchGesture>
{selectMode && viewMode === 'tracks' ? (
<SelectionActionBar
count={selectedIds.size}
onPlayNext={batchPlayNext}
onAddToQueue={batchAddToQueue}
onAddToPlaylist={() => setPlaylistPickerOpen(true)}
/>
) : null}
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
<ActionSheet
visible={sortSheetOpen}
title="Sort tracks by"
items={SORT_OPTIONS.map((option) => ({
key: option,
label: TRACK_SORT_LABELS[option],
selected: option === trackSort,
onPress: () => {
setTrackSort(option);
setSortSheetOpen(false);
},
}))}
onClose={() => setSortSheetOpen(false)}
/>
{playlistPickerOpen ? (
<PlaylistPickerSheet
tracks={selectedDbTracks()}
subtitle={`${selectedIds.size} ${selectedIds.size === 1 ? 'track' : 'tracks'}`}
onClose={() => setPlaylistPickerOpen(false)}
onAdded={() => {
commitHaptic();
exitSelection();
}}
/>
) : null}
{sortSheetOpen ? (
<AppSheet onClose={() => setSortSheetOpen(false)}>
<AppSheetSection label={sortSheetLabel} />
{sortItems.map(({ key, label, selected, onSelect }) => (
<AppSheetItem
key={key}
label={label}
selected={selected}
onPress={() => {
onSelect();
setSortSheetOpen(false);
}}
/>
))}
</AppSheet>
) : null}
</Screen>
);
}
@@ -243,8 +466,19 @@ const styles = StyleSheet.create({
paddingVertical: spacing.xs,
marginBottom: spacing.xs,
},
// Same vertical rhythm as sortTrigger so entering selection doesn't shift the list.
selectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs,
marginBottom: spacing.xs,
},
gridCell: {
flex: 1,
paddingHorizontal: spacing.xs,
},
listArea: {
flex: 1,
},
});
+88 -153
View File
@@ -1,20 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import {
useEffect,
useMemo,
useState
} from 'react';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { colors, radius, spacing } from '@/theme';
import { TrackActionsSheet, type TrackActionSheetItem } from '@/components/library/TrackActionsSheet';
import {
AppSheet,
AppSheetItem,
AppSheetTitle
} from '@/components/sheets/AppSheet';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { colors, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { artworkThumbUri, artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
import type { DbTrack } from '@/types/library';
@@ -56,6 +70,9 @@ export default function PlaylistScreen() {
const removeFromPlaylist = usePlaylistStore((s) => s.removeFromPlaylist);
const markPlayed = usePlaylistStore((s) => s.markPlayed);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
const [actionEntry, setActionEntry] = useState<PlaylistTrackEntry | null>(null);
const [missingEntry, setMissingEntry] = useState<PlaylistTrackEntry | null>(null);
@@ -120,7 +137,7 @@ export default function PlaylistScreen() {
// Move/remove only exist on real playlists; favorites rows use the standard
// sheet (its favorite toggle is the "remove" affordance there).
const extraItems: ActionSheetItem[] =
const extraItems: TrackActionSheetItem[] =
playlistId != null && actionEntry
? [
{
@@ -154,76 +171,27 @@ export default function PlaylistScreen() {
]
: [];
const meta = [
`${playable.length} ${playable.length === 1 ? 'track' : 'tracks'}`,
entries.length > playable.length ? `${entries.length - playable.length} missing` : null,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ');
return (
<Screen>
<Pressable style={styles.back} onPress={handleBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Library
</Text>
</Pressable>
<View style={styles.header}>
<View style={styles.art}>
{coverHash ? (
<Image
source={{ uri: artworkUri(coverHash) }}
style={styles.artImage}
contentFit="cover"
transition={120}
/>
) : (
<Ionicons
name={isFavorites ? 'heart' : 'musical-notes-outline'}
size={36}
color={isFavorites ? colors.accent : colors.textTertiary}
/>
)}
</View>
<View style={styles.headerMeta}>
<Text variant="heading" numberOfLines={2}>
{name}
</Text>
<Text variant="label">
{[
`${playable.length} ${playable.length === 1 ? 'track' : 'tracks'}`,
entries.length > playable.length ? `${entries.length - playable.length} missing` : null,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ')}
</Text>
<View style={styles.buttons}>
<Pressable
style={[styles.playButton, playable.length === 0 && styles.buttonDisabled]}
disabled={playable.length === 0}
onPress={() => startPlayback(0)}
accessibilityRole="button"
>
<Ionicons name="play" size={16} color={colors.bgPrimary} />
<Text variant="body" style={styles.playLabel}>
Play
</Text>
</Pressable>
<Pressable
style={[styles.shuffleButton, playable.length === 0 && styles.buttonDisabled]}
disabled={playable.length === 0}
onPress={startShuffle}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
</View>
</View>
<Screen padded={false} style={styles.screen}>
<FlashList
data={entries}
keyExtractor={(entry) => String(entry.id)}
showsVerticalScrollIndicator={false}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: insets.top + expandedHeight,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxl,
}}
renderItem={({ item }) =>
item.track ? (
<TrackRow
@@ -238,102 +206,69 @@ export default function PlaylistScreen() {
)
}
/>
<CollapsingHeader
artwork={
coverHash ? (
<Image source={{ uri: artworkUri(coverHash) }} style={styles.artFill} contentFit="cover" transition={150} />
) : (
<Ionicons
name={isFavorites ? 'heart' : 'musical-notes-outline'}
size={56}
color={isFavorites ? colors.accent : colors.textTertiary}
/>
)
}
backdropUri={coverHash ? artworkThumbUri(coverHash) : null}
title={name}
heroMeta={<Text variant="label">{meta}</Text>}
disabled={playable.length === 0}
onBack={handleBack}
onPlay={() => startPlayback(0)}
onShuffle={startShuffle}
scrollY={scrollY}
heroFaded={heroFaded}
collapsed={collapsed}
expandedHeight={expandedHeight}
onHeroBlockLayout={onHeroBlockLayout}
/>
<TrackActionsSheet
track={actionEntry?.track ?? null}
onClose={() => setActionEntry(null)}
extraItems={extraItems}
/>
<ActionSheet
visible={missingEntry !== null}
title={missingEntry?.fallback_title ?? 'Missing track'}
items={
playlistId != null && missingEntry
? [
{
key: 'remove',
label: 'Remove from playlist',
icon: 'remove-circle-outline',
destructive: true,
onPress: () => {
void removeFromPlaylist(playlistId, missingEntry.track_path);
setMissingEntry(null);
},
},
]
: []
}
onClose={() => setMissingEntry(null)}
/>
{missingEntry !== null ? (
<AppSheet onClose={() => setMissingEntry(null)}>
<AppSheetTitle
title={missingEntry.fallback_title ?? 'Missing track'}
subtitle={missingEntry.fallback_artist ?? 'Track not in library'}
/>
{playlistId != null ? (
<AppSheetItem
label="Remove from playlist"
icon="remove-circle-outline"
destructive
onPress={() => {
void removeFromPlaylist(playlistId, missingEntry.track_path);
setMissingEntry(null);
}}
/>
) : null}
</AppSheet>
) : null}
</Screen>
);
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.md,
alignSelf: 'flex-start',
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
},
header: {
flexDirection: 'row',
gap: spacing.lg,
marginBottom: spacing.lg,
},
art: {
width: 128,
height: 128,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
artFill: {
width: '100%',
height: '100%',
},
headerMeta: {
flex: 1,
justifyContent: 'center',
gap: spacing.xs,
},
buttons: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
buttonDisabled: {
opacity: 0.4,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
},
missingRow: {
flexDirection: 'row',
alignItems: 'center',
+11 -3
View File
@@ -1,4 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import {
useEffect,
useMemo,
useState
} from 'react';
import {
ActivityIndicator,
Alert,
@@ -9,7 +13,7 @@ import {
StyleSheet,
TextInput,
useWindowDimensions,
View,
View
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
@@ -20,7 +24,11 @@ import { MarqueeText } from '@/components/MarqueeText';
import { Screen } from '@/components/Screen';
import { SeekBar } from '@/components/SeekBar';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
+16 -3
View File
@@ -1,11 +1,24 @@
import { useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import {
ActivityIndicator,
Pressable,
StyleSheet,
View
} from 'react-native';
import {
CameraView,
useCameraPermissions,
type BarcodeScanningResult
} from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
export default function DesktopRemoteScanScreen() {
+6 -2
View File
@@ -8,13 +8,17 @@ import {
ScrollView,
StyleSheet,
TextInput,
View,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import type { LastFmScrobbleProtocol } from '@/types/lastFm';
+13 -2
View File
@@ -1,10 +1,21 @@
import { useEffect } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Switch, View } from 'react-native';
import {
Alert,
Pressable,
ScrollView,
StyleSheet,
Switch,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { requestLastFmFlush } from '@/services/lastfm';
import type { LastFmProfileStatus } from '@/types/lastFm';
+13 -4
View File
@@ -1,5 +1,10 @@
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
import {
View,
Pressable,
StyleSheet,
useWindowDimensions
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
@@ -11,7 +16,7 @@ import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
withTiming
} from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
@@ -22,7 +27,11 @@ import { WaveformSeekBar } from '@/components/WaveformSeekBar';
import { Visualizer } from '@/components/Visualizer';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { QueueTray } from '@/components/queue/QueueTray';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
import { motion } from '@/theme/motion';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
@@ -37,7 +46,7 @@ import {
skipToNext,
skipToPrevious,
togglePlay,
toggleShuffle,
toggleShuffle
} from '@/audio/playbackController';
const DISMISS_DISTANCE = 140;
+5 -1
View File
@@ -1,5 +1,9 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
+6 -2
View File
@@ -7,13 +7,17 @@ import {
ScrollView,
StyleSheet,
TextInput,
View,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceType } from '@/types/remote';
+12 -2
View File
@@ -1,11 +1,21 @@
import { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import {
Alert,
Pressable,
ScrollView,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceRow, RemoteSyncProgress } from '@/types/remote';
+30
View File
@@ -281,6 +281,36 @@ export async function enqueueEnd(track: Track): Promise<void> {
if (originalOrder) originalOrder.push(track.id);
}
/** Insert tracks after the current one in the given order (batch "Play next"). */
export async function enqueueTopMany(tracks: Track[]): Promise<void> {
if (tracks.length === 0) return;
await ensurePlayerReady();
await queueLoadSettled();
const activeIndex = await TrackPlayer.getActiveTrackIndex();
const activeTrack = await TrackPlayer.getActiveTrack();
const insertBefore = activeIndex === undefined ? undefined : activeIndex + 1;
await TrackPlayer.add(tracks.map(toRntpTrack), insertBefore);
// One settle-gated native read keeps the mirror consistent for any batch size.
await useQueueStore.getState().refreshFromNative();
if (originalOrder) {
const currentId = activeTrack ? rntpTrackId(activeTrack) : null;
const pos = currentId ? originalOrder.indexOf(currentId) : -1;
const ids = tracks.map((track) => track.id);
if (pos >= 0) originalOrder.splice(pos + 1, 0, ...ids);
else originalOrder.unshift(...ids);
}
}
/** Append tracks to the end of the queue in the given order (batch "Add to queue"). */
export async function enqueueEndMany(tracks: Track[]): Promise<void> {
if (tracks.length === 0) return;
await ensurePlayerReady();
await queueLoadSettled();
await TrackPlayer.add(tracks.map(toRntpTrack));
await useQueueStore.getState().refreshFromNative();
if (originalOrder) originalOrder.push(...tracks.map((track) => track.id));
}
// ── Queue-tray operations ────────────────────────────────────────────────────
// The tray works in absolute RNTP queue indices. Single-item reorders use
// RNTP's native move; group operations rebuild the upcoming tail so the current
+31 -1
View File
@@ -1,6 +1,10 @@
import { View, StyleSheet } from 'react-native';
import { Text } from './Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import type { Track } from '@/types/audio';
/** A single mono pill (e.g. "FLAC", "24-BIT", "48.0 kHz"). */
@@ -17,13 +21,18 @@ export function Badge({ label }: { label: string }) {
/**
* Format badge row for a track. Mirrors desktop `TrackList.tsx`:
* `format.toUpperCase()` and `${(sampleRate / 1000).toFixed(1)} kHz`.
*
* `variant="plain"` drops the pill chrome for muted middot-joined text — used in
* dense track lists where the pills read as too first-class next to the title.
*/
export function FormatBadges({
track,
wrap = true,
variant = 'pill',
}: {
track: Pick<Track, 'format' | 'bitDepth' | 'sampleRate'>;
wrap?: boolean;
variant?: 'pill' | 'plain';
}) {
const labels: string[] = [];
if (track.format) labels.push(track.format.toUpperCase());
@@ -32,6 +41,22 @@ export function FormatBadges({
if (labels.length === 0) return null;
if (variant === 'plain') {
// Compact so it hugs its content instead of filling the row: drop the "-BIT"
// and "kHz" words and fold depth/rate into "24/44.1".
const parts: string[] = [];
if (track.format) parts.push(track.format.toUpperCase());
const rate = track.sampleRate ? (track.sampleRate / 1000).toFixed(1) : null;
if (track.bitDepth && rate) parts.push(`${track.bitDepth}/${rate}`);
else if (rate) parts.push(rate);
else if (track.bitDepth) parts.push(`${track.bitDepth}-bit`);
return (
<Text variant="mono" style={styles.plain} numberOfLines={1}>
{parts.join(' · ')}
</Text>
);
}
return (
<View style={[styles.row, !wrap && styles.rowNoWrap]}>
{labels.map((label) => (
@@ -63,6 +88,11 @@ const styles = StyleSheet.create({
fontSize: 10,
letterSpacing: 0.5,
},
plain: {
color: colors.textTertiary,
fontSize: 10,
letterSpacing: 0.3,
},
});
export default FormatBadges;
+1 -1
View File
@@ -5,7 +5,7 @@ import {
type LayoutChangeEvent,
type StyleProp,
type TextStyle,
type ViewStyle,
type ViewStyle
} from 'react-native';
import type { TextLayoutEvent } from 'react-native/Libraries/Types/CoreEventTypes';
import Animated, {
+11 -2
View File
@@ -1,12 +1,21 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
import {
View,
Pressable,
StyleSheet,
type LayoutChangeEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Text } from './Text';
import { AstraLogo } from './AstraLogo';
import { SpectrumCurve } from './SpectrumCurve';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { skipToNext, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
+6 -2
View File
@@ -1,11 +1,15 @@
import { useEffect, useMemo, useRef } from 'react';
import {
useEffect,
useMemo,
useRef
} from 'react';
import {
PaintStyle,
Skia,
SkiaPictureView,
StrokeCap,
StrokeJoin,
type SkPicture,
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
import { useScopeStore } from '@/scope/scopeStore';
+5 -1
View File
@@ -1,4 +1,8 @@
import { View, StyleSheet, type ViewProps } from 'react-native';
import {
StyleSheet,
View,
type ViewProps
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing } from '@/theme';
+11 -2
View File
@@ -1,7 +1,16 @@
import { useRef, useState } from 'react';
import { View, StyleSheet, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent
} from 'react-native';
import { Text } from './Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { formatDuration } from '@/lib/format';
const THUMB_SIZE = 12;
+152
View File
@@ -0,0 +1,152 @@
import { useEffect } from 'react';
import {
Pressable,
StyleSheet,
View,
type LayoutChangeEvent
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming
} from 'react-native-reanimated';
import {
colors,
fonts,
radius
} from '@/theme';
import { motion } from '@/theme/motion';
const THUMB_INSET = 3;
export interface Segment {
key: string;
label: string;
}
interface SegmentedControlProps {
segments: Segment[];
value: string;
onChange: (key: string) => void;
}
/**
* Equal-width segmented control on the TabBar "playhead" pattern: one glass
* track, a thumb that glides to the active segment, labels cross-fading to the
* accent via interpolateColor on Animated.Text. Spring-free per theme/motion.
*/
export function SegmentedControl({ segments, value, onChange }: SegmentedControlProps) {
const count = segments.length;
const activeIndex = Math.max(
0,
segments.findIndex((segment) => segment.key === value),
);
const trackWidth = useSharedValue(0);
const position = useSharedValue(activeIndex);
useEffect(() => {
position.value = withTiming(activeIndex, motion.snap);
}, [activeIndex, position]);
const thumbStyle = useAnimatedStyle(() => {
const segment = count > 0 ? (trackWidth.value - THUMB_INSET * 2) / count : 0;
return {
width: segment,
transform: [{ translateX: position.value * segment }],
};
});
const onTrackLayout = (e: LayoutChangeEvent) => {
trackWidth.value = e.nativeEvent.layout.width;
};
return (
<View style={styles.track} onLayout={onTrackLayout}>
<Animated.View style={[styles.thumb, thumbStyle]} pointerEvents="none" />
{segments.map((segment) => (
<SegmentButton
key={segment.key}
label={segment.label}
focused={segment.key === value}
onPress={() => onChange(segment.key)}
/>
))}
</View>
);
}
function SegmentButton({
label,
focused,
onPress,
}: {
label: string;
focused: boolean;
onPress: () => void;
}) {
// 0 = inactive, 1 = active; drives the label colour cross-fade.
const progress = useSharedValue(focused ? 1 : 0);
useEffect(() => {
progress.value = withTiming(focused ? 1 : 0, motion.quick);
}, [focused, progress]);
const labelStyle = useAnimatedStyle(() => ({
color: interpolateColor(
progress.value,
[0, 1],
[colors.textSecondary, colors.accentTextStrong],
),
}));
return (
<Pressable
style={({ pressed }) => [styles.segment, pressed && styles.segmentPressed]}
onPress={onPress}
accessibilityRole="tab"
accessibilityState={{ selected: focused }}
>
<Animated.Text style={[styles.label, labelStyle]} numberOfLines={1}>
{label}
</Animated.Text>
</Pressable>
);
}
const styles = StyleSheet.create({
track: {
flexDirection: 'row',
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
padding: THUMB_INSET,
},
thumb: {
position: 'absolute',
top: THUMB_INSET,
bottom: THUMB_INSET,
left: THUMB_INSET,
backgroundColor: colors.glassHighlight,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
},
segment: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
},
segmentPressed: {
opacity: 0.72,
},
label: {
fontSize: 12,
fontFamily: fonts.sans.medium,
},
});
export default SegmentedControl;
+6 -2
View File
@@ -1,4 +1,8 @@
import { useEffect, useMemo, useRef } from 'react';
import {
useEffect,
useMemo,
useRef
} from 'react';
import {
PaintStyle,
Skia,
@@ -6,7 +10,7 @@ import {
StrokeCap,
StrokeJoin,
TileMode,
type SkPicture,
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
import { colors } from '@/theme';
+11 -3
View File
@@ -1,12 +1,20 @@
import { useState, type ReactNode } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import {
StyleSheet,
View,
type LayoutChangeEvent
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
import {
Gesture,
GestureDetector,
type GestureType
} from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
withTiming
} from 'react-native-reanimated';
import { colors } from '@/theme';
import { motion } from '@/theme/motion';
+13 -3
View File
@@ -1,15 +1,25 @@
import { useEffect, useState } from 'react';
import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
import {
View,
Pressable,
StyleSheet,
type LayoutChangeEvent
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
withTiming
} from 'react-native-reanimated';
import { MiniPlayer } from './MiniPlayer';
import { colors, fonts, layout, spacing } from '@/theme';
import {
colors,
fonts,
layout,
spacing
} from '@/theme';
import { motion } from '@/theme/motion';
type IconName = keyof typeof Ionicons.glyphMap;
+10 -2
View File
@@ -1,6 +1,14 @@
import type { ReactNode } from 'react';
import { Text as RNText, type TextProps as RNTextProps, StyleSheet } from 'react-native';
import { colors, fonts, fontSize } from '@/theme';
import {
StyleSheet,
Text as RNText,
type TextProps as RNTextProps
} from 'react-native';
import {
colors,
fonts,
fontSize
} from '@/theme';
type Variant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono';
+5 -1
View File
@@ -1,5 +1,9 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
StyleSheet,
Pressable,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text';
import { SpectrumCurve } from './SpectrumCurve';
+19 -3
View File
@@ -1,6 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
import { Canvas, Group, Path, Skia, rect } from '@shopify/react-native-skia';
import {
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent
} from 'react-native';
import {
Canvas,
Group,
Path,
Skia,
rect
} from '@shopify/react-native-skia';
import { Text } from './Text';
import { colors, spacing } from '@/theme';
import { formatDuration } from '@/lib/format';
+24 -4
View File
@@ -1,11 +1,31 @@
import { Pressable, StyleSheet, Switch, View } from 'react-native';
import {
Pressable,
StyleSheet,
Switch,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import type { EQBand } from '@/types/audio';
import { EQ_MAX_FREQUENCY, EQ_MAX_GAIN_DB, EQ_MAX_Q, EQ_MIN_FREQUENCY, EQ_MIN_Q, isPassEQBandType } from '@/audio/eq';
import {
EQ_MAX_FREQUENCY,
EQ_MAX_GAIN_DB,
EQ_MAX_Q,
EQ_MIN_FREQUENCY,
EQ_MIN_Q,
isPassEQBandType
} from '@/audio/eq';
import { EQSlider } from './EQSlider';
import { BAND_TYPE_LABEL, formatFreq, formatGain } from './format';
import {
BAND_TYPE_LABEL,
formatFreq,
formatGain
} from './format';
interface BandDetailPanelProps {
band: EQBand | null;
+15 -3
View File
@@ -1,9 +1,21 @@
import { Pressable, ScrollView, StyleSheet } from 'react-native';
import {
Pressable,
ScrollView,
StyleSheet
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import type { EQBand } from '@/types/audio';
import { formatFreq, formatGain, gainColor } from './format';
import {
formatFreq,
formatGain,
gainColor
} from './format';
interface BandStripProps {
bands: EQBand[];
+8 -4
View File
@@ -1,9 +1,13 @@
import { useMemo, useRef, useState } from 'react';
import {
useMemo,
useRef,
useState
} from 'react';
import {
View,
StyleSheet,
type GestureResponderEvent,
type LayoutChangeEvent,
type LayoutChangeEvent
} from 'react-native';
import {
Canvas,
@@ -12,7 +16,7 @@ import {
Group,
Path,
Skia,
type SkPath,
type SkPath
} from '@shopify/react-native-skia';
import { Text } from '@/components/Text';
import { SpectrumCurve } from '@/components/SpectrumCurve';
@@ -25,7 +29,7 @@ import {
freqToX,
gainToY,
xToFreq,
yToGain,
yToGain
} from './eqGraphMath';
const HIT_RADIUS = 34;
+7 -48
View File
@@ -1,6 +1,4 @@
import { Pressable, StyleSheet, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { SegmentedControl } from '@/components/SegmentedControl';
import type { EQMode } from '@/types/audio';
const MODES: { key: EQMode; label: string }[] = [
@@ -8,7 +6,7 @@ const MODES: { key: EQMode; label: string }[] = [
{ key: 'graphic', label: 'Graphic' },
];
/** Two-segment Parametric | Graphic control (ViewModeSwitcher styling, fixed row). */
/** Two-segment Parametric | Graphic control (shared SegmentedControl). */
export function EQModeSwitcher({
value,
onChange,
@@ -17,51 +15,12 @@ export function EQModeSwitcher({
onChange: (mode: EQMode) => void;
}) {
return (
<View style={styles.row}>
{MODES.map((mode) => {
const active = mode.key === value;
return (
<Pressable
key={mode.key}
onPress={() => onChange(mode.key)}
style={[styles.pill, active && styles.pillActive]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text variant="label" style={[styles.label, active && styles.labelActive]}>
{mode.label}
</Text>
</Pressable>
);
})}
</View>
<SegmentedControl
segments={MODES}
value={value}
onChange={(key) => onChange(key as EQMode)}
/>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: spacing.sm,
},
pill: {
flex: 1,
alignItems: 'center',
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingVertical: spacing.xs + 2,
},
pillActive: {
borderColor: colors.accent,
backgroundColor: 'rgba(56, 189, 248, 0.08)',
},
label: {
color: colors.textSecondary,
},
labelActive: {
color: colors.accent,
},
});
export default EQModeSwitcher;
+6 -2
View File
@@ -4,10 +4,14 @@ import {
View,
StyleSheet,
type GestureResponderEvent,
type LayoutChangeEvent,
type LayoutChangeEvent
} from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
const THUMB = 16;
+7 -2
View File
@@ -3,11 +3,16 @@ import {
Pressable,
StyleSheet,
View,
type KeyboardTypeOptions,
type KeyboardTypeOptions
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, fonts, radius, spacing } from '@/theme';
import {
colors,
fonts,
radius,
spacing
} from '@/theme';
import { EqSheet } from './EqSheet';
interface EQValueEditSheetProps {
+5 -1
View File
@@ -5,7 +5,11 @@ import { EQ_MAX_GAIN_DB, EQ_MIN_GAIN_DB } from '@/audio/eq';
import { GRAPHIC_BANDS } from '@/audio/graphicEq';
import { GraphicResponseCurve } from './GraphicResponseCurve';
import { VerticalEQSlider } from './VerticalEQSlider';
import { formatFreqHz, formatGain, gainColor } from './format';
import {
formatFreqHz,
formatGain,
gainColor
} from './format';
interface GraphicEQPanelProps {
gains: number[];
+14 -3
View File
@@ -1,13 +1,24 @@
import { useMemo, useState } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import { Canvas, DashPathEffect, Group, Path, Skia, type SkPath } from '@shopify/react-native-skia';
import {
StyleSheet,
View,
type LayoutChangeEvent
} from 'react-native';
import {
Canvas,
DashPathEffect,
Group,
Path,
Skia,
type SkPath
} from '@shopify/react-native-skia';
import { colors } from '@/theme';
import type { EQBand } from '@/types/audio';
import {
EQ_MAX_FREQUENCY,
EQ_MAX_GAIN_DB,
EQ_MIN_FREQUENCY,
computeCombinedEQMagnitude,
computeCombinedEQMagnitude
} from '@/audio/eq';
import { GRAPHIC_BANDS, buildGraphicBands } from '@/audio/graphicEq';
import { GRAPH_SAMPLE_RATE, buildResponseFill } from './eqGraphMath';
+5 -1
View File
@@ -3,7 +3,11 @@ import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import type { EQPreset } from '@/types/audio';
import { EqSheet, EqSheetItem, EqSheetSection } from './EqSheet';
import {
EqSheet,
EqSheetItem,
EqSheetSection
} from './EqSheet';
interface PresetSheetProps {
presets: EQPreset[];
+11 -2
View File
@@ -1,8 +1,17 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, fonts, radius, spacing } from '@/theme';
import {
colors,
fonts,
radius,
spacing
} from '@/theme';
import { EqSheet } from './EqSheet';
interface SavePresetSheetProps {
+1 -1
View File
@@ -3,7 +3,7 @@ import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
type LayoutChangeEvent
} from 'react-native';
import { colors, radius } from '@/theme';
+12 -3
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library';
@@ -16,7 +24,8 @@ export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () =>
source={{ uri: artUri }}
style={styles.artImage}
contentFit="cover"
transition={120}
recyclingKey={album.identity_key}
transition={null}
/>
) : (
<AstraLogo size={36} />
+10 -2
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library';
+192
View File
@@ -0,0 +1,192 @@
/* eslint-disable react-hooks/immutability -- Reanimated shared values are mutable gesture state. */
import { useMemo, useState } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { tickHaptic } from '@/lib/haptics';
import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture';
import { RAIL_LETTERS } from '@/lib/letterIndex';
const CELL_HEIGHT = 17;
const RAIL_PAD = spacing.xs;
const RAIL_HEIGHT = RAIL_LETTERS.length * CELL_HEIGHT + RAIL_PAD * 2;
const BUBBLE_SIZE = 52;
interface AlphabetRailProps {
/** Letters present in the current list — the rest render dimmed. */
activeLetters: ReadonlySet<string>;
onJumpToLetter: (letter: string) => void;
}
/**
* A-Z scrubber overlaid on the right edge of a library list. Fixed cell
* geometry (full #A-Z always rendered) keeps the pointer math trivial; one
* haptic tick per letter crossed. The magnified letter bubble tracks the
* finger's vertical position (Y driven on the UI thread; the letter text only
* changes on a letter-cross). Blocks the pull-to-search gesture so a scrub at
* scroll-top never arms the search indicator.
*/
export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProps) {
const pullSearchRef = usePullSearchGestureRef();
const [scrubLetter, setScrubLetter] = useState<string | null>(null);
const lastLetter = useSharedValue('');
// Rail's top offset inside the (vertically-centered) wrap + the finger's Y
// within the rail, so the bubble can be placed in wrap-space.
const railTop = useSharedValue(0);
const bubbleY = useSharedValue(0);
const scrubTo = (letter: string) => {
setScrubLetter(letter);
onJumpToLetter(letter);
};
const endScrub = () => setScrubLetter(null);
const pan = useMemo(() => {
const gesture = Gesture.Pan()
.minDistance(0)
.onBegin((event) => {
'worklet';
lastLetter.value = '';
const y = Math.max(0, Math.min(RAIL_HEIGHT, event.y));
bubbleY.value = railTop.value + y;
const index = Math.max(
0,
Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT))
);
const letter = RAIL_LETTERS[index];
lastLetter.value = letter;
runOnJS(tickHaptic)();
runOnJS(scrubTo)(letter);
})
.onUpdate((event) => {
'worklet';
const y = Math.max(0, Math.min(RAIL_HEIGHT, event.y));
// Track the finger every frame for a smooth bubble; the letter/haptic
// below only fire when the letter actually changes.
bubbleY.value = railTop.value + y;
const index = Math.max(
0,
Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT))
);
const letter = RAIL_LETTERS[index];
if (letter === lastLetter.value) return;
lastLetter.value = letter;
runOnJS(tickHaptic)();
runOnJS(scrubTo)(letter);
})
.onFinalize(() => {
'worklet';
lastLetter.value = '';
runOnJS(endScrub)();
});
return pullSearchRef ? gesture.blocksExternalGesture(pullSearchRef) : gesture;
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrubTo/endScrub capture the latest onJumpToLetter via render closure
}, [lastLetter, bubbleY, railTop, pullSearchRef, onJumpToLetter]);
const bubbleStyle = useAnimatedStyle(() => ({
transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }],
}));
const onRailLayout = (e: LayoutChangeEvent) => {
railTop.value = e.nativeEvent.layout.y;
};
return (
<View style={styles.wrap} pointerEvents="box-none">
{scrubLetter ? (
<Animated.View style={[styles.bubble, bubbleStyle]} pointerEvents="none">
<Text variant="mono" style={styles.bubbleLetter}>
{scrubLetter}
</Text>
</Animated.View>
) : null}
<GestureDetector gesture={pan}>
<View style={styles.rail} hitSlop={{ left: 12, right: 8 }} onLayout={onRailLayout}>
{RAIL_LETTERS.map((letter) => {
const present = activeLetters.has(letter);
const scrubbing = letter === scrubLetter;
return (
<View key={letter} style={styles.cell}>
<Text
variant="mono"
style={[
styles.letter,
present ? styles.letterPresent : styles.letterAbsent,
scrubbing && styles.letterScrubbing,
]}
>
{letter}
</Text>
</View>
);
})}
</View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
position: 'absolute',
top: 0,
bottom: 0,
// Overhang the Screen's horizontal padding so the rail hugs the true edge.
right: -spacing.md,
justifyContent: 'center',
alignItems: 'flex-end',
},
// A faint scrim strip rather than a bordered glass pill: transparent enough to
// feel like an overlay, dark enough to keep the letters legible over bright art.
rail: {
width: 16,
paddingVertical: RAIL_PAD,
alignItems: 'center',
backgroundColor: 'rgba(8, 10, 15, 0.35)',
borderRadius: radius.pill,
},
cell: {
height: CELL_HEIGHT,
alignItems: 'center',
justifyContent: 'center',
},
letter: {
fontSize: 10,
lineHeight: CELL_HEIGHT,
},
letterPresent: {
color: colors.textSecondary,
},
letterAbsent: {
color: colors.textTertiary,
opacity: 0.4,
},
letterScrubbing: {
color: colors.accentTextStrong,
},
bubble: {
position: 'absolute',
top: 0,
right: 34,
width: BUBBLE_SIZE,
height: BUBBLE_SIZE,
borderRadius: radius.lg,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
elevation: 8,
shadowColor: '#000',
shadowOpacity: 0.28,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
},
bubbleLetter: {
fontSize: 26,
lineHeight: 30,
color: colors.accentTextStrong,
},
});
+90
View File
@@ -0,0 +1,90 @@
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import {
colors,
spacing,
radius
} from '@/theme';
import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library';
/** 2-column grid cell: square art (2x2 album mosaic when available) + counts, matching the album grid. */
export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: () => void }) {
const useMosaic = artist.artwork_hashes.length >= 4;
const hashes = useMosaic ? artist.artwork_hashes.slice(0, 4) : artist.artwork_hashes.slice(0, 1);
const albums = `${artist.album_count} ${artist.album_count === 1 ? 'album' : 'albums'}`;
const tracks = `${artist.track_count} ${artist.track_count === 1 ? 'track' : 'tracks'}`;
return (
<Pressable style={styles.item} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
{hashes.length === 0 ? (
<Ionicons name="person" size={44} color={colors.textTertiary} />
) : useMosaic ? (
hashes.map((hash) => (
<Image
key={hash}
source={{ uri: artworkUri(hash) }}
style={styles.mosaicTile}
contentFit="cover"
recyclingKey={hash}
transition={null}
/>
))
) : (
<Image
source={{ uri: artworkUri(hashes[0]) }}
style={styles.artImage}
contentFit="cover"
recyclingKey={hashes[0]}
transition={null}
/>
)}
</View>
<Text variant="body" numberOfLines={1} style={styles.name}>
{artist.artist}
</Text>
<Text variant="label" numberOfLines={1}>
{albums} · {tracks}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
item: {
flex: 1,
marginBottom: spacing.lg,
},
art: {
aspectRatio: 1,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginBottom: spacing.sm,
},
artImage: {
width: '100%',
height: '100%',
},
mosaicTile: {
width: '50%',
height: '50%',
},
name: {
fontSize: 14,
},
});
+10 -2
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library';
+459
View File
@@ -0,0 +1,459 @@
import {
useRef,
useState,
type ReactNode
} from 'react';
import {
Pressable,
StyleSheet,
View,
useWindowDimensions,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
useSharedValue,
type SharedValue
} from 'react-native-reanimated';
import {
Canvas,
LinearGradient,
Rect,
vec
} from '@shopify/react-native-skia';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
} from '@/theme';
// Collapsing detail header. An absolute container whose height shrinks with the
// scroll and clips its faded content, so the track list (padded to the expanded
// height) rises to meet it — no mid-scroll dead space. The artwork is a single
// element that shrinks/tucks into the top-left corner as the header collapses.
// Tune on device.
const ART_SIZE = 210;
const ART_COLLAPSED = 34;
const BAR_H = 48;
const ART_TOP = 44;
/** Top of the title/meta/buttons block, just below the artwork. */
const HERO_BLOCK_TOP = 262;
/** Gap below the buttons to the header's bottom edge (where row 1 sits at rest). */
const BLOCK_BOTTOM_PAD = 20;
/** Expanded header height below the inset until the block is measured. */
const FALLBACK_EXPANDED = 424;
const FADE_H = 150;
/**
* Scroll plumbing. Measures the hero block so the header height (and thus the
* collapse distance and the list's top padding) adapt to the title length
* long titles don't clip the buttons. `heroFaded` disables the big (now-invisible)
* buttons before `collapsed` enables the header's icon buttons, so neither steals
* taps mid-transition.
*/
export function useDetailCollapse() {
const scrollY = useSharedValue(0);
const [expandedHeight, setExpandedHeight] = useState(FALLBACK_EXPANDED);
const expandedRef = useRef(FALLBACK_EXPANDED);
const ref = useRef({ heroFaded: false, collapsed: false });
const [state, setState] = useState({ heroFaded: false, collapsed: false });
const onHeroBlockLayout = (e: LayoutChangeEvent) => {
const next = HERO_BLOCK_TOP + e.nativeEvent.layout.height + BLOCK_BOTTOM_PAD;
if (Math.abs(next - expandedRef.current) > 1) {
expandedRef.current = next;
setExpandedHeight(next);
}
};
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = e.nativeEvent.contentOffset.y;
scrollY.value = y;
const dist = expandedRef.current - BAR_H;
const heroFaded = y >= 60;
const collapsed = y >= dist - 40;
if (heroFaded !== ref.current.heroFaded || collapsed !== ref.current.collapsed) {
ref.current = { heroFaded, collapsed };
setState({ heroFaded, collapsed });
}
};
return {
scrollY,
...state,
expandedHeight,
onHeroBlockLayout,
onScroll,
scrollEventThrottle: 16 as const,
};
}
function BottomFade() {
const [width, setWidth] = useState(0);
return (
<View style={styles.fade} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
{width > 0 ? (
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={width} height={FADE_H}>
<LinearGradient
start={vec(0, 0)}
end={vec(0, FADE_H)}
colors={[`${colors.bgPrimary}00`, colors.bgPrimary]}
/>
</Rect>
</Canvas>
) : null}
</View>
);
}
export function CollapsingHeader({
artwork,
backdropUri,
title,
heroMeta,
disabled,
onBack,
onPlay,
onShuffle,
scrollY,
heroFaded,
collapsed,
expandedHeight,
onHeroBlockLayout,
}: {
/** Fills the morphing art container (album cover, artist mosaic, or fallback). */
artwork: ReactNode;
backdropUri: string | null;
title: string;
/** The middle of the hero block, between title and buttons (subtitle/meta or stat chips). */
heroMeta: ReactNode;
disabled?: boolean;
onBack: () => void;
onPlay: () => void;
onShuffle: () => void;
scrollY: SharedValue<number>;
heroFaded: boolean;
collapsed: boolean;
/** Measured expanded height below the inset (from useDetailCollapse). */
expandedHeight: number;
onHeroBlockLayout: (e: LayoutChangeEvent) => void;
}) {
const insets = useSafeAreaInsets();
const { width: W } = useWindowDimensions();
const dist = expandedHeight - BAR_H;
const settle = dist - 36;
const maxH = insets.top + expandedHeight;
const minH = insets.top + BAR_H;
const barCenterY = insets.top + BAR_H / 2;
const artExpandedTop = insets.top + ART_TOP;
const thumbCenterX = spacing.md + 24 + spacing.sm + ART_COLLAPSED / 2;
const txTarget = thumbCenterX - W / 2;
const tyTarget = barCenterY - (artExpandedTop + ART_SIZE / 2);
const scaleTarget = ART_COLLAPSED / ART_SIZE;
const containerStyle = useAnimatedStyle(() => ({
height: interpolate(scrollY.value, [0, dist], [maxH, minH], Extrapolation.CLAMP),
}));
const artStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(scrollY.value, [dist * 0.4, settle], [0, txTarget], Extrapolation.CLAMP) },
{ translateY: interpolate(scrollY.value, [0, settle], [0, tyTarget], Extrapolation.CLAMP) },
{ scale: interpolate(scrollY.value, [30, settle], [1, scaleTarget], Extrapolation.CLAMP) },
],
}));
// Lift + shrink as it fades, so the block recedes into the header rather than
// being covered by the rising rows.
const heroBlockStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, 80], [1, 0], Extrapolation.CLAMP),
transform: [
{ translateY: interpolate(scrollY.value, [0, 95], [0, -30], Extrapolation.CLAMP) },
{ scale: interpolate(scrollY.value, [0, 95], [1, 0.97], Extrapolation.CLAMP) },
],
}));
// The buttons sit closest to the incoming rows — lift and fade them a touch
// ahead of the text for a light stagger.
const heroButtonsStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, 58], [1, 0], Extrapolation.CLAMP),
transform: [{ translateY: interpolate(scrollY.value, [0, 75], [0, -16], Extrapolation.CLAMP) }],
}));
const barBgStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [dist - 100, dist - 20], [0, 1], Extrapolation.CLAMP),
}));
const labelStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, 45], [1, 0], Extrapolation.CLAMP),
}));
const barTitleStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [settle - 30, settle + 10], [0, 1], Extrapolation.CLAMP),
}));
const barIconsStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [settle - 24, settle + 16], [0, 1], Extrapolation.CLAMP),
transform: [
{ scale: interpolate(scrollY.value, [settle - 24, settle + 16], [0.7, 1], Extrapolation.CLAMP) },
],
}));
return (
<Animated.View style={[styles.container, containerStyle]} pointerEvents="box-none">
{/* Blurred wash (fixed tall, clipped by the shrinking container) + fade at the bottom edge. */}
<View style={[styles.wash, { height: maxH }]} pointerEvents="none">
{backdropUri ? (
<Image source={{ uri: backdropUri }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} transition={null} />
) : (
<View style={[StyleSheet.absoluteFill, styles.washFallback]} />
)}
<View style={styles.scrim} />
</View>
<BottomFade />
<Animated.View style={[styles.barBg, { height: minH }, barBgStyle]} pointerEvents="none">
{backdropUri ? (
<>
<Image source={{ uri: backdropUri }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} transition={null} />
<View style={styles.barScrim} />
</>
) : (
<View style={styles.barSolid} />
)}
</Animated.View>
<Animated.View
style={[styles.heroBlock, { top: insets.top + HERO_BLOCK_TOP }, heroBlockStyle]}
pointerEvents={heroFaded ? 'none' : 'auto'}
onLayout={onHeroBlockLayout}
>
<Text variant="title" numberOfLines={2} adjustsFontSizeToFit minimumFontScale={0.72} style={styles.heroTitle}>
{title}
</Text>
{heroMeta}
<Animated.View style={[styles.actionRow, heroButtonsStyle]}>
<Pressable
style={[styles.actionButton, styles.primaryAction, disabled && styles.disabledAction]}
onPress={onPlay}
disabled={disabled}
accessibilityRole="button"
>
<Ionicons name="play" size={17} color={colors.bgPrimary} />
<Text variant="body" style={styles.primaryActionText}>
Play
</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.secondaryAction, disabled && styles.disabledAction]}
onPress={onShuffle}
disabled={disabled}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={17} color={colors.accent} />
<Text variant="body" color={colors.accent} style={styles.secondaryActionText}>
Shuffle
</Text>
</Pressable>
</Animated.View>
</Animated.View>
<Pressable
onPress={onBack}
hitSlop={8}
style={[styles.chevron, { top: barCenterY - 12, left: spacing.md }]}
accessibilityRole="button"
accessibilityLabel="Back"
>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Animated.Text style={[styles.label, { top: barCenterY - 10, left: spacing.md + 26 }, labelStyle]}>
Library
</Animated.Text>
<Animated.Text
numberOfLines={1}
style={[
styles.barTitle,
{ top: barCenterY - 12, left: thumbCenterX + ART_COLLAPSED / 2 + spacing.sm, right: 84 },
barTitleStyle,
]}
>
{title}
</Animated.Text>
<Animated.View
style={[styles.barIcons, { top: barCenterY - 16, right: spacing.md }, barIconsStyle]}
pointerEvents={collapsed ? 'auto' : 'none'}
>
<Pressable onPress={onPlay} disabled={disabled} hitSlop={6} style={styles.iconBtn}>
<Ionicons name="play" size={20} color={colors.accent} />
</Pressable>
<Pressable onPress={onShuffle} disabled={disabled} hitSlop={6} style={styles.iconBtn}>
<Ionicons name="shuffle" size={20} color={colors.accent} />
</Pressable>
</Animated.View>
{/* Rendered last so the large art sits on top of the header text until it tucks away. */}
<Animated.View
style={[
styles.art,
{ top: artExpandedTop, left: (W - ART_SIZE) / 2, width: ART_SIZE, height: ART_SIZE },
artStyle,
]}
pointerEvents="none"
>
{artwork}
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
overflow: 'hidden',
},
wash: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
washFallback: {
backgroundColor: colors.bgTertiary,
opacity: 0.55,
},
scrim: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.bgPrimary,
opacity: 0.5,
},
fade: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: FADE_H,
},
barBg: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
barScrim: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.bgSecondary,
opacity: 0.82,
},
barSolid: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.bgSecondary,
},
heroBlock: {
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
alignItems: 'center',
gap: spacing.xs,
},
heroTitle: {
maxWidth: '100%',
textAlign: 'center',
},
actionRow: {
width: '100%',
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.lg,
},
actionButton: {
flex: 1,
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
},
primaryAction: {
backgroundColor: colors.accent,
},
secondaryAction: {
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.glassBg,
},
disabledAction: {
opacity: 0.45,
},
primaryActionText: {
color: colors.bgPrimary,
fontWeight: '600',
},
secondaryActionText: {
fontWeight: '600',
},
chevron: {
position: 'absolute',
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
},
label: {
position: 'absolute',
color: colors.textSecondary,
fontSize: 15,
},
barTitle: {
position: 'absolute',
color: colors.textPrimary,
fontSize: 18,
fontWeight: '600',
},
barIcons: {
position: 'absolute',
flexDirection: 'row',
gap: spacing.xs,
},
iconBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
art: {
position: 'absolute',
borderRadius: radius.lg,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
},
});
+10 -2
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
StyleSheet,
View,
Pressable
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
export function EmptyLibrary() {
const router = useRouter();
+138 -6
View File
@@ -5,22 +5,37 @@ import {
View,
type GestureResponderEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type NativeSyntheticEvent
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { Text } from '@/components/Text';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import {
AppSheet,
AppSheetItem,
AppSheetTitle
} from '@/components/sheets/AppSheet';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import { playTracks } from '@/audio/playbackController';
import {
playTracks,
shuffleTracks,
enqueueTopMany,
enqueueEndMany
} from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import {
buildFolderTree,
flattenFolderTree,
type FlattenedFolderTreeRow,
type FolderTreeNode
} from '@/library/folderTree';
import { formatDuration } from '@/lib/format';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import type { DbTrack } from '@/types/library';
@@ -33,16 +48,32 @@ interface FoldersViewProps {
function FolderRow({
row,
onToggle,
onPlay,
onShuffle,
onOpenActions,
}: {
row: Extract<FlattenedFolderTreeRow, { type: 'folder' }>;
onToggle: (nodeId: string) => void;
onPlay: (node: FolderTreeNode) => void;
onShuffle: (node: FolderTreeNode) => void;
onOpenActions: (node: FolderTreeNode) => void;
}) {
const { node, depth, isExpanded } = row;
const play = (event: GestureResponderEvent) => {
event.stopPropagation();
onPlay(node);
};
const shuffle = (event: GestureResponderEvent) => {
event.stopPropagation();
onShuffle(node);
};
return (
<Pressable
style={styles.folderRow}
style={({ pressed }) => [styles.folderRow, pressed && styles.rowPressed]}
onPress={() => onToggle(node.id)}
onLongPress={() => onOpenActions(node)}
accessibilityRole="button"
accessibilityState={{ expanded: isExpanded }}
>
@@ -70,6 +101,24 @@ function FolderRow({
<Text variant="mono" style={styles.count}>
{node.totalTrackCount}
</Text>
<Pressable
style={({ pressed }) => [styles.folderButton, pressed && styles.folderButtonPressed]}
onPress={play}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={`Play ${node.name}`}
>
<Ionicons name="play" size={16} color={colors.accent} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.folderButton, pressed && styles.folderButtonPressed]}
onPress={shuffle}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={`Shuffle ${node.name}`}
>
<Ionicons name="shuffle" size={16} color={colors.textSecondary} />
</Pressable>
</Pressable>
);
}
@@ -95,7 +144,11 @@ function FolderTrackRow({
return (
<Pressable
style={[styles.trackRow, active && styles.trackRowActive]}
style={({ pressed }) => [
styles.trackRow,
active && styles.trackRowActive,
pressed && styles.rowPressed,
]}
onPress={playFolderTrack}
onLongPress={onOpenActions}
accessibilityRole="button"
@@ -132,10 +185,29 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(() => new Set());
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [actionFolder, setActionFolder] = useState<FolderTreeNode | null>(null);
const tree = useMemo(() => buildFolderTree(folders, tracks), [folders, tracks]);
const rows = useMemo(() => flattenFolderTree(tree, expandedNodeIds), [expandedNodeIds, tree]);
// Folder-level playback runs the whole subtree (subfolders included), in tree order.
const playFolder = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return;
void playTracks(node.subtreeTracks.map(dbTrackToTrack), 0);
};
const shuffleFolder = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return;
void shuffleTracks(node.subtreeTracks.map(dbTrackToTrack));
};
const playFolderNext = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return;
void enqueueTopMany(node.subtreeTracks.map(dbTrackToTrack));
};
const queueFolder = (node: FolderTreeNode) => {
if (node.subtreeTracks.length === 0) return;
void enqueueEndMany(node.subtreeTracks.map(dbTrackToTrack));
};
const toggleFolder = (nodeId: string) => {
setExpandedNodeIds((current) => {
const next = new Set(current);
@@ -173,7 +245,13 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
contentContainerStyle={styles.listContent}
renderItem={({ item }) =>
item.type === 'folder' ? (
<FolderRow row={item} onToggle={toggleFolder} />
<FolderRow
row={item}
onToggle={toggleFolder}
onPlay={playFolder}
onShuffle={shuffleFolder}
onOpenActions={setActionFolder}
/>
) : (
<FolderTrackRow
row={item}
@@ -184,6 +262,46 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
{actionFolder ? (
<AppSheet onClose={() => setActionFolder(null)}>
<AppSheetTitle
title={actionFolder.name}
subtitle={`${actionFolder.totalTrackCount} ${actionFolder.totalTrackCount === 1 ? 'track' : 'tracks'}`}
/>
<AppSheetItem
label="Play"
icon="play"
onPress={() => {
playFolder(actionFolder);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Shuffle"
icon="shuffle"
onPress={() => {
shuffleFolder(actionFolder);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Play next"
icon="play-skip-forward"
onPress={() => {
playFolderNext(actionFolder);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Add to queue"
icon="list-outline"
onPress={() => {
queueFolder(actionFolder);
setActionFolder(null);
}}
/>
</AppSheet>
) : null}
</>
);
}
@@ -215,6 +333,17 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
fontSize: 12,
},
folderButton: {
width: 32,
height: 32,
flexShrink: 0,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
},
folderButtonPressed: {
backgroundColor: colors.glassBg,
},
trackRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -227,6 +356,9 @@ const styles = StyleSheet.create({
trackRowActive: {
backgroundColor: colors.accentGlow,
},
rowPressed: {
opacity: 0.72,
},
trackMeta: {
flex: 1,
minWidth: 0,
+10 -2
View File
@@ -1,8 +1,16 @@
import { View, Pressable, StyleSheet } from 'react-native';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { artworkUri } from '@/library/artwork';
export function PlaylistRow({
+36 -9
View File
@@ -5,17 +5,25 @@ import {
StyleSheet,
Alert,
type NativeScrollEvent,
type NativeSyntheticEvent,
type NativeSyntheticEvent
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import {
AppSheet,
AppSheetItem,
AppSheetTitle
} from '@/components/sheets/AppSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { Playlist } from '@/types/playlist';
@@ -164,6 +172,14 @@ export function PlaylistsView({
onLongPress={() => setMenuFor(item)}
/>
)}
ListEmptyComponent={
<View style={styles.empty}>
<Ionicons name="musical-notes-outline" size={28} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
No playlists yet. Create one or import an M3U below.
</Text>
</View>
}
ListFooterComponent={
<View style={styles.actions}>
<Pressable
@@ -190,12 +206,14 @@ export function PlaylistsView({
}
/>
<ActionSheet
visible={menuFor !== null}
title={menuFor === 'favorites' ? 'Favorites' : (menuFor?.name ?? '')}
items={menuItems}
onClose={() => setMenuFor(null)}
/>
{menuFor !== null ? (
<AppSheet onClose={() => setMenuFor(null)}>
<AppSheetTitle title={menuFor === 'favorites' ? 'Favorites' : menuFor.name} />
{menuItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
</AppSheet>
) : null}
<TextPromptModal
visible={prompt !== null}
title={prompt?.kind === 'rename' ? 'Rename playlist' : 'New playlist'}
@@ -225,6 +243,15 @@ const styles = StyleSheet.create({
gap: spacing.md,
marginTop: spacing.lg,
},
empty: {
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.xl,
},
emptyText: {
textAlign: 'center',
maxWidth: 260,
},
action: {
flexDirection: 'row',
alignItems: 'center',
+1 -1
View File
@@ -1,4 +1,4 @@
import { View, StyleSheet } from 'react-native';
import { StyleSheet, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
@@ -0,0 +1,109 @@
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
interface SelectionActionBarProps {
count: number;
onPlayNext: () => void;
onAddToQueue: () => void;
onAddToPlaylist: () => void;
}
/** Bottom batch-action bar for library multi-select (QueueTray action-bar language). */
export function SelectionActionBar({
count,
onPlayNext,
onAddToQueue,
onAddToPlaylist,
}: SelectionActionBarProps) {
const disabled = count === 0;
return (
<View style={styles.bar}>
<BarButton
icon="play-skip-forward"
label={`Play next (${count})`}
accessibilityLabel={`Play ${count} selected tracks next`}
disabled={disabled}
onPress={onPlayNext}
/>
<BarButton
icon="list-outline"
label={`Queue (${count})`}
accessibilityLabel={`Add ${count} selected tracks to the queue`}
disabled={disabled}
onPress={onAddToQueue}
/>
<BarButton
icon="add-circle-outline"
label={`Playlist (${count})`}
accessibilityLabel={`Add ${count} selected tracks to a playlist`}
disabled={disabled}
onPress={onAddToPlaylist}
/>
</View>
);
}
function BarButton({
icon,
label,
accessibilityLabel,
disabled,
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
accessibilityLabel: string;
disabled: boolean;
onPress: () => void;
}) {
return (
<Pressable
style={({ pressed }) => [
styles.button,
pressed && styles.buttonPressed,
disabled && styles.buttonDisabled,
]}
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
>
<Ionicons name={icon} size={18} color={colors.accent} />
<Text variant="label" style={styles.label} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.bgTertiary,
},
button: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingVertical: spacing.md,
},
buttonPressed: {
opacity: 0.7,
},
buttonDisabled: {
opacity: 0.4,
},
label: {
color: colors.accent,
},
});
+16 -148
View File
@@ -1,13 +1,16 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { AppSheet, AppSheetItem, AppSheetSection, type AppSheetItemProps } from '@/components/sheets/AppSheet';
import {
AppSheet,
AppSheetItem,
AppSheetSection,
AppSheetTitle,
type AppSheetItemProps,
} from '@/components/sheets/AppSheet';
import { PlaylistPickerSheet } from '@/components/sheets/PlaylistPickerSheet';
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
import { colors, fonts, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library';
@@ -40,33 +43,19 @@ function TrackActionsSheetInner({
extraItems = [],
}: TrackActionsSheetProps & { track: DbTrack }) {
const router = useRouter();
const [step, setStep] = useState<'menu' | 'pickPlaylist' | 'newPlaylist'>(initialStep);
const [playlistName, setPlaylistName] = useState('');
const [step, setStep] = useState<'menu' | 'pickPlaylist'>(initialStep);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const playlists = usePlaylistStore((s) => s.playlists);
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
const addTracksToPlaylist = usePlaylistStore((s) => s.addTracksToPlaylist);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const artistName =
groupingMode === 'fileTags' ? resolveStrictBrowseArtist(track) : resolveCanonicalBrowseArtist(track);
const trimmedPlaylistName = playlistName.trim();
const closeAndRun = (run: () => void) => {
onClose();
run();
};
const addToNewPlaylist = () => {
if (!trimmedPlaylistName) return;
void (async () => {
const playlist = await createPlaylist(trimmedPlaylistName);
await addTracksToPlaylist(playlist.id, [track]);
})();
onClose();
};
const menuItems: TrackActionSheetItem[] = [
{
key: 'play-next',
@@ -119,78 +108,20 @@ function TrackActionsSheetInner({
},
];
const pickItems: TrackActionSheetItem[] = [
...playlists.map((playlist) => ({
key: `playlist-${playlist.id}`,
label: playlist.name,
icon: 'musical-notes-outline' as const,
onPress: () => closeAndRun(() => void addTracksToPlaylist(playlist.id, [track])),
})),
{
key: 'new-playlist',
label: 'New playlist...',
icon: 'add',
onPress: () => setStep('newPlaylist'),
},
];
if (step === 'pickPlaylist') {
return (
<AppSheet onClose={onClose}>
<SheetTitle title="Add to playlist" subtitle={track.title} />
{initialStep === 'menu' ? (
<AppSheetItem label="Track actions" icon="arrow-back" onPress={() => setStep('menu')} />
) : null}
{playlists.length === 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
No playlists yet.
</Text>
) : null}
{pickItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
</AppSheet>
);
}
if (step === 'newPlaylist') {
return (
<AppSheet onClose={onClose}>
<SheetTitle title="New playlist" subtitle={track.title} />
<BottomSheetTextInput
value={playlistName}
onChangeText={setPlaylistName}
placeholder="Playlist name"
placeholderTextColor={colors.textTertiary}
style={styles.input}
autoFocus
returnKeyType="done"
onSubmitEditing={addToNewPlaylist}
selectionColor={colors.accent}
/>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={() => setStep('pickPlaylist')}>
<Text variant="label" color={colors.textSecondary}>
Back
</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.create, !trimmedPlaylistName && styles.createDisabled]}
disabled={!trimmedPlaylistName}
onPress={addToNewPlaylist}
>
<Text variant="label" color={colors.accentTextStrong}>
Create
</Text>
</Pressable>
</View>
</AppSheet>
<PlaylistPickerSheet
tracks={[track]}
subtitle={track.title}
onClose={onClose}
onBackToMenu={initialStep === 'menu' ? () => setStep('menu') : undefined}
/>
);
}
return (
<AppSheet onClose={onClose}>
<SheetTitle title={track.title} subtitle={track.artist} />
<AppSheetTitle title={track.title} subtitle={track.artist} />
{menuItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
@@ -205,66 +136,3 @@ function TrackActionsSheetInner({
</AppSheet>
);
}
function SheetTitle({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<View style={styles.titleBlock}>
<Text variant="heading" numberOfLines={1} style={styles.title}>
{title}
</Text>
{subtitle ? (
<Text variant="label" numberOfLines={1} color={colors.textSecondary}>
{subtitle}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
titleBlock: {
marginTop: spacing.xs,
marginBottom: spacing.sm,
gap: 2,
},
title: {
paddingRight: spacing.lg,
},
empty: {
paddingVertical: spacing.sm,
},
input: {
color: colors.textPrimary,
fontFamily: fonts.sans.regular,
fontSize: 16,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
create: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
createDisabled: {
opacity: 0.4,
},
});
+51 -10
View File
@@ -1,5 +1,10 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet, type GestureResponderEvent } from 'react-native';
import {
View,
Pressable,
StyleSheet,
type GestureResponderEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
@@ -7,7 +12,11 @@ import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { RemoteSourceBadge } from '@/components/RemoteSourceBadge';
import { SwipeableRow } from '@/components/SwipeableRow';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { formatDuration } from '@/lib/format';
import { trackArtworkThumbSource } from '@/library/artwork';
import { dbTrackToTrack } from '@/library/trackAdapter';
@@ -16,6 +25,7 @@ import type { DbTrack } from '@/types/library';
const ART_SIZE = 44;
const ROW_MIN_HEIGHT = ART_SIZE + (spacing.sm + 2) * 2;
const ACTIONS_BUTTON = 34;
export function TrackRow({
track,
@@ -26,6 +36,9 @@ export function TrackRow({
subtitle,
active = false,
swipeToQueue = true,
selectionMode = false,
selected = false,
onToggleSelect,
}: {
track: DbTrack;
onPress: () => void;
@@ -40,6 +53,10 @@ export function TrackRow({
active?: boolean;
/** Swipe right → play next, swipe left → add to queue. Off in queue-like lists. */
swipeToQueue?: boolean;
/** Multi-select: press toggles selection, checkbox leads, swipes/actions off. */
selectionMode?: boolean;
selected?: boolean;
onToggleSelect?: () => void;
}) {
// Key the artwork by hash (local) or identity path (remote) so the error fallback
// and FlashList recycling work for both.
@@ -55,11 +72,20 @@ export function TrackRow({
const row = (
<Pressable
style={styles.row}
onPress={onPress}
onLongPress={onLongPress ?? onOpenActions}
style={[styles.row, selectionMode && selected && styles.rowSelected]}
onPress={selectionMode ? onToggleSelect : onPress}
onLongPress={selectionMode ? onToggleSelect : (onLongPress ?? onOpenActions)}
accessibilityRole="button"
accessibilityState={selectionMode ? { selected } : undefined}
>
{selectionMode ? (
<Ionicons
name={selected ? 'checkmark-circle' : 'ellipse-outline'}
size={22}
color={selected ? colors.accent : colors.textTertiary}
style={styles.checkbox}
/>
) : null}
<View style={styles.art}>
{thumbUri ? (
<Image
@@ -99,6 +125,7 @@ export function TrackRow({
<View style={styles.badges}>
<RemoteSourceBadge sourceType={track.source_type} />
<FormatBadges
variant="plain"
track={{
format: track.format,
bitDepth: track.bit_depth ?? undefined,
@@ -108,11 +135,11 @@ export function TrackRow({
</View>
</View>
<Text variant="mono" style={styles.duration}>
<Text variant="mono" style={[styles.duration, selectionMode && styles.durationSelection]}>
{formatDuration(track.duration)}
</Text>
{onOpenActions ? (
{onOpenActions && !selectionMode ? (
<Pressable
style={({ pressed }) => [styles.actionsButton, pressed && styles.actionsButtonPressed]}
onPress={openActions}
@@ -126,7 +153,7 @@ export function TrackRow({
</Pressable>
);
if (!swipeToQueue) return row;
if (!swipeToQueue || selectionMode) return row;
return (
<SwipeableRow
@@ -159,6 +186,12 @@ const styles = StyleSheet.create({
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
rowSelected: {
backgroundColor: colors.glassHighlight,
},
checkbox: {
flexShrink: 0,
},
art: {
width: ART_SIZE,
height: ART_SIZE,
@@ -176,8 +209,11 @@ const styles = StyleSheet.create({
height: '100%',
},
trackNumber: {
width: 24,
width: 20,
flexShrink: 0,
// The row's uniform gap plus a right-aligned box leaves the number floating
// too far off the artwork; pull it back in toward the cover.
marginLeft: -spacing.sm,
fontSize: 12,
color: colors.textTertiary,
textAlign: 'right',
@@ -206,8 +242,13 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
textAlign: 'right',
},
// Selection mode drops the actions button; reserve its footprint so the
// duration holds its position instead of sliding to the row edge.
durationSelection: {
marginRight: ACTIONS_BUTTON + spacing.md,
},
actionsButton: {
width: 34,
width: ACTIONS_BUTTON,
height: 34,
flexShrink: 0,
borderRadius: radius.pill,
+6 -49
View File
@@ -1,6 +1,4 @@
import { Pressable, ScrollView, StyleSheet } from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { SegmentedControl } from '@/components/SegmentedControl';
export type LibraryViewMode = 'albums' | 'artists' | 'tracks' | 'playlists' | 'folders';
@@ -20,51 +18,10 @@ export function ViewModeSwitcher({
onChange: (mode: LibraryViewMode) => void;
}) {
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
{MODES.map((mode) => {
const active = mode.key === value;
return (
<Pressable
key={mode.key}
onPress={() => onChange(mode.key)}
style={[styles.pill, active && styles.pillActive]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text
variant="label"
style={[styles.label, active && styles.labelActive]}
>
{mode.label}
</Text>
</Pressable>
);
})}
</ScrollView>
<SegmentedControl
segments={MODES}
value={value}
onChange={(key) => onChange(key as LibraryViewMode)}
/>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: spacing.sm,
},
pill: {
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
},
pillActive: {
borderColor: colors.accent,
backgroundColor: 'rgba(56, 189, 248, 0.08)',
},
label: {
color: colors.textSecondary,
},
labelActive: {
color: colors.accent,
},
});
+16 -7
View File
@@ -4,22 +4,27 @@ import {
useEffect,
useMemo,
useRef,
useState,
useState
} from 'react';
import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
import {
Pressable,
StyleSheet,
View,
useWindowDimensions
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
useBottomSheetScrollableCreator,
useBottomSheetScrollableCreator
} from '@gorhom/bottom-sheet';
import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
import {
Gesture,
GestureDetector,
type GestureType,
type GestureType
} from 'react-native-gesture-handler';
import Animated, {
runOnJS,
@@ -27,13 +32,17 @@ import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
type SharedValue
} from 'react-native-reanimated';
import type { Track as RntpTrack } from 'react-native-track-player';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { SwipeableRow } from '@/components/SwipeableRow';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { motion } from '@/theme/motion';
import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
import { useQueueStore } from '@/stores/queueStore';
@@ -44,7 +53,7 @@ import {
removeManyFromQueue,
requeueManyToTop,
requeueToTop,
setUpcoming,
setUpcoming
} from '@/audio/playbackController';
import { useQueue } from './useQueue';
+21 -5
View File
@@ -8,7 +8,7 @@ import {
useRef,
useState,
type MutableRefObject,
type ReactNode,
type ReactNode
} from 'react';
import {
ScrollView as RNScrollView,
@@ -16,7 +16,7 @@ import {
View,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollViewProps,
type ScrollViewProps
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
@@ -24,11 +24,19 @@ import {
GestureDetector,
ScrollView as GestureScrollView,
type GestureType,
type NativeViewGestureHandlerProps,
type NativeViewGestureHandlerProps
} from 'react-native-gesture-handler';
import { runOnJS, runOnUI, useSharedValue } from 'react-native-reanimated';
import {
runOnJS,
runOnUI,
useSharedValue
} from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
const OPEN_THRESHOLD = 76;
@@ -86,6 +94,14 @@ export const PullSearchScrollView = forwardRef<RNScrollView, PullSearchScrollVie
}
);
/**
* The pull-to-search Pan gesture ref, so overlaid gestures (e.g. the A-Z rail)
* can declare relations like `.blocksExternalGesture(ref)` against it.
*/
export function usePullSearchGestureRef(): PullSearchGestureRef | null {
return useContext(PullSearchGestureContext)?.gestureRef ?? null;
}
export function useScrollTopGate(initialAtTop = true) {
const atTopRef = useRef(initialAtTop);
const [atTop, setAtTop] = useState(initialAtTop);
+24 -5
View File
@@ -1,4 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {
Keyboard,
Modal,
@@ -7,7 +12,7 @@ import {
TextInput,
View,
useWindowDimensions,
type GestureResponderEvent,
type GestureResponderEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
@@ -16,10 +21,20 @@ import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import {
colors,
fonts,
fontSize,
radius,
spacing
} from '@/theme';
import { enqueueTop, playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { albumArtworkSource, artworkUri, trackArtworkThumbSource } from '@/library/artwork';
import {
albumArtworkSource,
artworkUri,
trackArtworkThumbSource
} from '@/library/artwork';
import { multiFieldScore, MIN_SCORE_THRESHOLD } from '@/lib/fuzzySearch';
import { formatDuration } from '@/lib/format';
import { commitHaptic } from '@/lib/haptics';
@@ -27,7 +42,11 @@ import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
import type { Album, Artist, DbTrack } from '@/types/library';
import type {
Album,
Artist,
DbTrack
} from '@/types/library';
import type { Playlist } from '@/types/playlist';
type IconName = keyof typeof Ionicons.glyphMap;
+11 -2
View File
@@ -1,8 +1,17 @@
import { Modal, Pressable, StyleSheet, View } from 'react-native';
import {
Modal,
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
export interface ActionSheetItem {
key: string;
+34 -3
View File
@@ -1,14 +1,22 @@
import { useCallback, type ReactNode } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
StyleSheet,
Pressable,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetView,
type BottomSheetBackdropProps,
type BottomSheetBackdropProps
} from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
const insets = useSafeAreaInsets();
@@ -50,6 +58,21 @@ export function AppSheetSection({ label }: { label: string }) {
);
}
export function AppSheetTitle({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<View style={styles.titleBlock}>
<Text variant="heading" numberOfLines={1} style={styles.title}>
{title}
</Text>
{subtitle ? (
<Text variant="label" numberOfLines={1} color={colors.textSecondary}>
{subtitle}
</Text>
) : null}
</View>
);
}
export interface AppSheetItemProps {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
@@ -109,6 +132,14 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
marginBottom: spacing.xs,
},
titleBlock: {
marginTop: spacing.xs,
marginBottom: spacing.sm,
gap: 2,
},
title: {
paddingRight: spacing.lg,
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -0,0 +1,163 @@
import { useState } from 'react';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import {
AppSheet,
AppSheetItem,
AppSheetTitle
} from '@/components/sheets/AppSheet';
import {
colors,
fonts,
radius,
spacing
} from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { DbTrack } from '@/types/library';
interface PlaylistPickerSheetProps {
/** Tracks to add (in order) to the chosen or newly created playlist. */
tracks: DbTrack[];
/** Context line under the sheet title, e.g. a track title or "12 tracks". */
subtitle?: string;
onClose: () => void;
/** Renders a back item returning to the caller's own menu (track actions). */
onBackToMenu?: () => void;
/** Fires only when tracks were actually added (not on cancel/dismiss). */
onAdded?: () => void;
}
/** Two-step "add to playlist" sheet: pick an existing playlist or create one. */
export function PlaylistPickerSheet({
tracks,
subtitle,
onClose,
onBackToMenu,
onAdded,
}: PlaylistPickerSheetProps) {
const [step, setStep] = useState<'pick' | 'create'>('pick');
const [playlistName, setPlaylistName] = useState('');
const playlists = usePlaylistStore((s) => s.playlists);
const addTracksToPlaylist = usePlaylistStore((s) => s.addTracksToPlaylist);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const trimmedPlaylistName = playlistName.trim();
const addToExisting = (playlistId: number) => {
onClose();
void addTracksToPlaylist(playlistId, tracks);
onAdded?.();
};
const addToNewPlaylist = () => {
if (!trimmedPlaylistName) return;
void (async () => {
const playlist = await createPlaylist(trimmedPlaylistName);
await addTracksToPlaylist(playlist.id, tracks);
})();
onClose();
onAdded?.();
};
if (step === 'create') {
return (
<AppSheet onClose={onClose}>
<AppSheetTitle title="New playlist" subtitle={subtitle} />
<BottomSheetTextInput
value={playlistName}
onChangeText={setPlaylistName}
placeholder="Playlist name"
placeholderTextColor={colors.textTertiary}
style={styles.input}
autoFocus
returnKeyType="done"
onSubmitEditing={addToNewPlaylist}
selectionColor={colors.accent}
/>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={() => setStep('pick')}>
<Text variant="label" color={colors.textSecondary}>
Back
</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.create, !trimmedPlaylistName && styles.createDisabled]}
disabled={!trimmedPlaylistName}
onPress={addToNewPlaylist}
>
<Text variant="label" color={colors.accentTextStrong}>
Create
</Text>
</Pressable>
</View>
</AppSheet>
);
}
return (
<AppSheet onClose={onClose}>
<AppSheetTitle title="Add to playlist" subtitle={subtitle} />
{onBackToMenu ? (
<AppSheetItem label="Track actions" icon="arrow-back" onPress={onBackToMenu} />
) : null}
{playlists.length === 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
No playlists yet.
</Text>
) : null}
{playlists.map((playlist) => (
<AppSheetItem
key={playlist.id}
label={playlist.name}
icon="musical-notes-outline"
onPress={() => addToExisting(playlist.id)}
/>
))}
<AppSheetItem label="New playlist..." icon="add" onPress={() => setStep('create')} />
</AppSheet>
);
}
const styles = StyleSheet.create({
empty: {
paddingVertical: spacing.sm,
},
input: {
color: colors.textPrimary,
fontFamily: fonts.sans.regular,
fontSize: 16,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
create: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
createDisabled: {
opacity: 0.4,
},
});
+14 -2
View File
@@ -1,7 +1,19 @@
import { useState } from 'react';
import { Modal, Pressable, StyleSheet, TextInput, View } from 'react-native';
import {
Modal,
Pressable,
StyleSheet,
TextInput,
View
} from 'react-native';
import { Text } from '@/components/Text';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import {
colors,
fonts,
fontSize,
radius,
spacing
} from '@/theme';
interface TextPromptModalProps {
visible: boolean;
+30
View File
@@ -0,0 +1,30 @@
import type { Album } from '@/types/library';
export type AlbumSort = 'artist' | 'name' | 'recently_added' | 'year';
export const ALBUM_SORT_LABELS: Record<AlbumSort, string> = {
artist: 'Artist',
name: 'Name',
recently_added: 'Recently added',
year: 'Year',
};
/** 'artist' is the DB's native order (getAlbums); others sort a copy. */
export function sortAlbums(albums: Album[], sort: AlbumSort): Album[] {
switch (sort) {
case 'artist':
return albums;
case 'name':
return [...albums].sort((a, b) => a.album.localeCompare(b.album));
case 'recently_added':
return [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at);
case 'year':
// Newest first, unknown years last, name tiebreak.
return [...albums].sort((a, b) => {
if (a.year == null && b.year == null) return a.album.localeCompare(b.album);
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year - a.year || a.album.localeCompare(b.album);
});
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { Artist } from '@/types/library';
export type ArtistSort = 'name' | 'track_count';
export const ARTIST_SORT_LABELS: Record<ArtistSort, string> = {
name: 'Name',
track_count: 'Track count',
};
/** 'name' is buildArtistList's native order; track count sorts a copy, most first. */
export function sortArtists(artists: Artist[], sort: ArtistSort): Artist[] {
switch (sort) {
case 'name':
return artists;
case 'track_count':
return [...artists].sort(
(a, b) => b.track_count - a.track_count || a.artist.localeCompare(b.artist)
);
}
}
+55
View File
@@ -0,0 +1,55 @@
// A-Z fast-scroll support: bucket a sorted list by first letter so the rail
// can jump to the first item of each letter. Diacritics fold to their base
// letter (NFD strip); digits/punctuation/non-Latin bucket under '#'.
export const RAIL_LETTERS: readonly string[] = [
'#',
...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)),
];
export interface LetterIndexEntry {
letter: string;
firstIndex: number;
}
export function letterFor(value: string): string {
const first = value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.charAt(0)
.toUpperCase();
return first >= 'A' && first <= 'Z' ? first : '#';
}
/** First occurrence of each letter in display order (input assumed sorted by key). */
export function buildLetterIndex<T>(
items: readonly T[],
keyOf: (item: T) => string
): LetterIndexEntry[] {
const firstByLetter = new Map<string, number>();
items.forEach((item, index) => {
const letter = letterFor(keyOf(item));
if (!firstByLetter.has(letter)) firstByLetter.set(letter, index);
});
return Array.from(firstByLetter, ([letter, firstIndex]) => ({ letter, firstIndex }));
}
/**
* Item index for a scrubbed rail letter. Exact bucket when present; otherwise
* the nearest previous existing letter, so scrubbing over gaps stays monotonic.
*/
export function resolveJumpIndex(index: readonly LetterIndexEntry[], letter: string): number | null {
if (index.length === 0) return null;
const exact = index.find((entry) => entry.letter === letter);
if (exact) return exact.firstIndex;
if (letter !== '#') {
for (let code = letter.charCodeAt(0) - 1; code >= 65; code--) {
const previous = index.find((entry) => entry.letter === String.fromCharCode(code));
if (previous) return previous.firstIndex;
}
const hash = index.find((entry) => entry.letter === '#');
if (hash) return hash.firstIndex;
}
return index[0].firstIndex;
}
+19 -2
View File
@@ -18,7 +18,7 @@ const UNKNOWN_ARTIST = 'Unknown Artist';
/** Track fields the grouping logic reads (subset of DbTrack, for testability). */
type ArtistTrackLike = Pick<
DbTrack,
'artist' | 'album_artist' | 'artwork_hash' | 'year' | 'added_at' | 'modified_at'
'artist' | 'album_artist' | 'artwork_hash' | 'year' | 'added_at' | 'modified_at' | 'album_identity_key'
>;
export function normalizeDisplay(value: string): string {
@@ -136,6 +136,9 @@ interface ArtistAggregate {
artworkYear: number;
artworkAddedAt: number;
artworkModifiedAt: number;
albumKeys: Set<string>;
/** First artwork hash seen per album — feeds the grid's 2x2 mosaic. */
albumArtwork: Map<string, string>;
}
/**
@@ -166,12 +169,18 @@ export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: Artist
artworkYear: -1,
artworkAddedAt: -1,
artworkModifiedAt: -1,
albumKeys: new Set(),
albumArtwork: new Map(),
};
byKey.set(key, aggregate);
}
aggregate.track_count += 1;
aggregate.albumKeys.add(track.album_identity_key);
if (!track.artwork_hash) continue;
if (!aggregate.albumArtwork.has(track.album_identity_key)) {
aggregate.albumArtwork.set(track.album_identity_key, track.artwork_hash);
}
const candidateYear = track.year ?? -1;
const better =
aggregate.artwork_hash == null ||
@@ -188,7 +197,15 @@ export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: Artist
}
return Array.from(byKey.values())
.map(({ artist, track_count, artwork_hash }) => ({ artist, track_count, artwork_hash }))
.map(({ artist, track_count, artwork_hash, albumKeys, albumArtwork }) => {
// Primary artwork first, then one distinct cover per further album (max 4).
const artwork_hashes: string[] = artwork_hash ? [artwork_hash] : [];
for (const hash of albumArtwork.values()) {
if (artwork_hashes.length >= 4) break;
if (!artwork_hashes.includes(hash)) artwork_hashes.push(hash);
}
return { artist, track_count, artwork_hash, album_count: albumKeys.size, artwork_hashes };
})
.sort((a, b) => a.artist.localeCompare(b.artist, undefined, { sensitivity: 'base' }));
}
+81 -3
View File
@@ -5,8 +5,10 @@ import {
getAlbums,
getAllTracks,
getRecentlyPlayedTracks,
getSetting,
getTrackCount,
markTrackPlayed,
setSetting,
} from '@/db/queries';
import { ensureArtworkThumbnails } from '@/library/artwork';
import { buildArtistList } from '@/library/artistGrouping';
@@ -18,7 +20,9 @@ import {
type ScanProgress,
type ScanResult,
} from '@/library/scanner';
import type { TrackSort } from '@/lib/trackSort';
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort';
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort';
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
import { usePlaylistStore } from './playlistStore';
import { useSettingsStore } from './settingsStore';
@@ -28,6 +32,38 @@ import { useSettingsStore } from './settingsStore';
*/
type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
const VIEW_MODE_KEY = 'library_view_mode';
const TRACK_SORT_KEY = 'library_track_sort';
const ALBUM_SORT_KEY = 'library_album_sort';
const ARTIST_SORT_KEY = 'library_artist_sort';
const VIEW_MODES: readonly ViewMode[] = ['tracks', 'albums', 'artists', 'playlists', 'folders'];
function parseViewMode(value: string | null): ViewMode | null {
return VIEW_MODES.includes(value as ViewMode) ? (value as ViewMode) : null;
}
function parseTrackSort(value: string | null): TrackSort | null {
return value !== null && value in TRACK_SORT_LABELS ? (value as TrackSort) : null;
}
function parseAlbumSort(value: string | null): AlbumSort | null {
return value !== null && value in ALBUM_SORT_LABELS ? (value as AlbumSort) : null;
}
function parseArtistSort(value: string | null): ArtistSort | null {
return value !== null && value in ARTIST_SORT_LABELS ? (value as ArtistSort) : null;
}
/** Fire-and-forget settings write so view/sort switching stays synchronous. */
function persistSetting(key: string, value: string) {
void openLibraryDb()
.then((db) => setSetting(db, key, value))
.catch(() => {
// Losing a view preference write is harmless; never surface it.
});
}
export type FolderWithCount = LibraryFolder & { track_count: number };
interface ScanProgressState {
@@ -49,6 +85,8 @@ interface LibraryStore {
totalTrackCount: number;
viewMode: ViewMode;
trackSort: TrackSort;
albumSort: AlbumSort;
artistSort: ArtistSort;
isScanning: boolean;
scanProgress: ScanProgressState;
scanError: string | null;
@@ -59,6 +97,8 @@ interface LibraryStore {
recomputeArtists: () => void;
setViewMode: (mode: ViewMode) => void;
setTrackSort: (sort: TrackSort) => void;
setAlbumSort: (sort: AlbumSort) => void;
setArtistSort: (sort: ArtistSort) => void;
addFolder: () => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
rescan: () => Promise<void>;
@@ -93,6 +133,8 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
totalTrackCount: 0,
viewMode: 'albums',
trackSort: 'artist',
albumSort: 'artist',
artistSort: 'name',
isScanning: false,
scanProgress: { ...IDLE_PROGRESS },
scanError: null,
@@ -107,6 +149,26 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
useSettingsStore.subscribe((state, prev) => {
if (state.artistGroupingMode !== prev.artistGroupingMode) get().recomputeArtists();
});
// Restore view preferences before the first render of the library screen.
const [savedViewMode, savedTrackSort, savedAlbumSort, savedArtistSort] =
await Promise.all([
getSetting(db, VIEW_MODE_KEY),
getSetting(db, TRACK_SORT_KEY),
getSetting(db, ALBUM_SORT_KEY),
getSetting(db, ARTIST_SORT_KEY),
]);
const viewMode = parseViewMode(savedViewMode);
const trackSort = parseTrackSort(savedTrackSort);
const albumSort = parseAlbumSort(savedAlbumSort);
const artistSort = parseArtistSort(savedArtistSort);
if (viewMode || trackSort || albumSort || artistSort) {
set({
...(viewMode ? { viewMode } : null),
...(trackSort ? { trackSort } : null),
...(albumSort ? { albumSort } : null),
...(artistSort ? { artistSort } : null),
});
}
await get().refresh();
set({ initialized: true });
// One-time recovery: the v3 migration marks tracks stale (mtime = -1)
@@ -161,9 +223,25 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
artists: buildArtistList(state.tracks, useSettingsStore.getState().artistGroupingMode),
})),
setViewMode: (viewMode) => set({ viewMode }),
setViewMode: (viewMode) => {
set({ viewMode });
persistSetting(VIEW_MODE_KEY, viewMode);
},
setTrackSort: (trackSort) => set({ trackSort }),
setTrackSort: (trackSort) => {
set({ trackSort });
persistSetting(TRACK_SORT_KEY, trackSort);
},
setAlbumSort: (albumSort) => {
set({ albumSort });
persistSetting(ALBUM_SORT_KEY, albumSort);
},
setArtistSort: (artistSort) => {
set({ artistSort });
persistSetting(ARTIST_SORT_KEY, artistSort);
},
addFolder: () => runScan(() => addFolderViaPicker({ onProgress })),
+3
View File
@@ -75,4 +75,7 @@ export interface Artist {
artist: string;
track_count: number;
artwork_hash: string | null;
album_count: number;
/** Primary hash first, then one distinct cover per further album (max 4) — grid mosaic. */
artwork_hashes: string[];
}