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