From 61e6147d3ec01929460edf701014e1db606be763 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:56:34 -0400 Subject: [PATCH] massive UI/UX overhaul --- app.json | 2 +- src/app/(tabs)/eq.tsx | 16 +- src/app/(tabs)/index.tsx | 19 +- src/app/(tabs)/library/album/[key].tsx | 245 +++++----- src/app/(tabs)/library/artist/[name].tsx | 277 ++++------- .../(tabs)/library/artist/[name]/albums.tsx | 6 +- .../library/artist/[name]/appearances.tsx | 6 +- .../(tabs)/library/artist/[name]/songs.tsx | 6 +- src/app/(tabs)/library/index.tsx | 428 ++++++++++++---- src/app/(tabs)/library/playlist/[id].tsx | 241 ++++----- src/app/desktop-remote.tsx | 14 +- src/app/desktop-remote/scan.tsx | 19 +- src/app/lastfm/edit.tsx | 8 +- src/app/lastfm/index.tsx | 15 +- src/app/now-playing.tsx | 17 +- src/app/recently-played.tsx | 6 +- src/app/sources/edit.tsx | 8 +- src/app/sources/index.tsx | 14 +- src/audio/playbackController.ts | 30 ++ src/components/FormatBadge.tsx | 32 +- src/components/MarqueeText.tsx | 2 +- src/components/MiniPlayer.tsx | 13 +- src/components/OscilloscopeWave.tsx | 8 +- src/components/Screen.tsx | 6 +- src/components/SeekBar.tsx | 13 +- src/components/SegmentedControl.tsx | 152 ++++++ src/components/SpectrumCurve.tsx | 8 +- src/components/SwipeableRow.tsx | 14 +- src/components/TabBar.tsx | 16 +- src/components/Text.tsx | 12 +- src/components/Visualizer.tsx | 6 +- src/components/WaveformSeekBar.tsx | 22 +- src/components/eq/BandDetailPanel.tsx | 28 +- src/components/eq/BandStrip.tsx | 18 +- src/components/eq/EQGraph.tsx | 12 +- src/components/eq/EQModeSwitcher.tsx | 55 +-- src/components/eq/EQSlider.tsx | 8 +- src/components/eq/EQValueEditSheet.tsx | 9 +- src/components/eq/GraphicEQPanel.tsx | 6 +- src/components/eq/GraphicResponseCurve.tsx | 17 +- src/components/eq/PresetSheet.tsx | 6 +- src/components/eq/SavePresetSheet.tsx | 13 +- src/components/eq/VerticalEQSlider.tsx | 2 +- src/components/library/AlbumGridItem.tsx | 15 +- src/components/library/AlbumRow.tsx | 12 +- src/components/library/AlphabetRail.tsx | 192 ++++++++ src/components/library/ArtistGridItem.tsx | 90 ++++ src/components/library/ArtistRow.tsx | 12 +- src/components/library/CollapsingDetail.tsx | 459 ++++++++++++++++++ src/components/library/EmptyLibrary.tsx | 12 +- src/components/library/FoldersView.tsx | 144 +++++- src/components/library/PlaylistRow.tsx | 12 +- src/components/library/PlaylistsView.tsx | 45 +- src/components/library/ScanProgress.tsx | 2 +- src/components/library/SelectionActionBar.tsx | 109 +++++ src/components/library/TrackActionsSheet.tsx | 164 +------ src/components/library/TrackRow.tsx | 61 ++- src/components/library/ViewModeSwitcher.tsx | 55 +-- src/components/queue/QueueTray.tsx | 23 +- src/components/search/PullSearchGesture.tsx | 26 +- src/components/search/QuickSearchOverlay.tsx | 29 +- src/components/sheets/ActionSheet.tsx | 13 +- src/components/sheets/AppSheet.tsx | 37 +- src/components/sheets/PlaylistPickerSheet.tsx | 163 +++++++ src/components/sheets/TextPromptModal.tsx | 16 +- src/lib/albumSort.ts | 30 ++ src/lib/artistSort.ts | 20 + src/lib/letterIndex.ts | 55 +++ src/library/artistGrouping.ts | 21 +- src/stores/libraryStore.ts | 84 +++- src/types/library.ts | 3 + 71 files changed, 2806 insertions(+), 953 deletions(-) create mode 100644 src/components/SegmentedControl.tsx create mode 100644 src/components/library/AlphabetRail.tsx create mode 100644 src/components/library/ArtistGridItem.tsx create mode 100644 src/components/library/CollapsingDetail.tsx create mode 100644 src/components/library/SelectionActionBar.tsx create mode 100644 src/components/sheets/PlaylistPickerSheet.tsx create mode 100644 src/lib/albumSort.ts create mode 100644 src/lib/artistSort.ts create mode 100644 src/lib/letterIndex.ts diff --git a/app.json b/app.json index 23af8c9..b4248aa 100644 --- a/app.json +++ b/app.json @@ -12,7 +12,7 @@ "supportsTablet": true }, "android": { - "package": "com.astra.mobile", + "package": "io.github.boof2015.astra", "backgroundColor": "#000000", "adaptiveIcon": { "backgroundColor": "#000000", diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index fc94007..1d93b7b 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -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'; diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index a0bb0e5..dc997b0 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -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'; diff --git a/src/app/(tabs)/library/album/[key].tsx b/src/app/(tabs)/library/album/[key].tsx index 100dfd0..433bbc8 100644 --- a/src/app/(tabs)/library/album/[key].tsx +++ b/src/app/(tabs)/library/album/[key].tsx @@ -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 ( + + + Disc {disc} + + ); +} + 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(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(() => { + 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 ( - - - - - Library - - - - - - {album && albumArtworkSource(album) ? ( - - ) : ( - - )} - - - - {album?.album ?? 'Album'} - - - {album?.artist ?? ''} - - - {[ - album?.year ? String(album.year) : null, - `${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`, - formatDuration(totalDuration), - ] - .filter(Boolean) - .join(' · ')} - - - playFrom(0)} accessibilityRole="button"> - - - Play - - - void shuffleTracks(tracks.map(dbTrackToTrack))} - accessibilityRole="button" - > - - - Shuffle - - - - - - + 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 }) => ( - 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' ? ( + + ) : ( + playFrom(item.index)} + onLongPress={() => setActionTrack(item.track)} + onOpenActions={() => setActionTrack(item.track)} + /> + ) + } + /> + + ) : ( + + ) + } + backdropUri={backdropUri} + title={album?.album ?? 'Album'} + heroMeta={ + <> + {album?.artist ? ( + + {album.artist} + + ) : null} + {meta} + + } + 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} /> - setActionTrack(null)} /> ); } 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, }, }); diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 5911790..6d0cef9 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -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 ( - - ); case 'section': { const target = item.target; return ( @@ -135,30 +146,56 @@ export default function ArtistScreen() { } }; - return ( - - - - - Library - - + const backdropHash = detail.artworkHashes[0] ?? null; + const disabled = detail.playbackTracks.length === 0; + return ( + item.key} showsVerticalScrollIndicator={false} renderItem={renderItem} - contentContainerStyle={styles.listContent} + onScroll={onScroll} + scrollEventThrottle={scrollEventThrottle} + contentContainerStyle={{ + paddingTop: insets.top + expandedHeight, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xxl, + }} + /> + + {detail.albums.length > 0 ? ( + + ) : null} + + {detail.totalDuration > 0 ? ( + + ) : null} + + } + disabled={disabled} + onBack={handleBack} + onPlay={playArtist} + onShuffle={shuffleArtist} + scrollY={scrollY} + heroFaded={heroFaded} + collapsed={collapsed} + expandedHeight={expandedHeight} + onHeroBlockLayout={onHeroBlockLayout} /> - setActionTrack(null)} /> ); } 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 ( - - - - - {artistName} - - - {detail.albums.length > 0 ? ( - - ) : null} - - {detail.totalDuration > 0 ? ( - - ) : null} - - - - - - - - Play - - - - - - Shuffle - - - - - ); -} - -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 ( - - {displayHashes.length === 0 ? ( - - ) : useMosaic ? ( - displayHashes.map((hash) => ( + if (display.length === 0) { + return ; + } + if (useMosaic) { + return ( + + {display.map((hash) => ( - )) - ) : ( - - )} - + ))} + + ); + } + return ( + ); } @@ -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', diff --git a/src/app/(tabs)/library/artist/[name]/albums.tsx b/src/app/(tabs)/library/artist/[name]/albums.tsx index 3476041..0f131f4 100644 --- a/src/app/(tabs)/library/artist/[name]/albums.tsx +++ b/src/app/(tabs)/library/artist/[name]/albums.tsx @@ -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'; diff --git a/src/app/(tabs)/library/artist/[name]/appearances.tsx b/src/app/(tabs)/library/artist/[name]/appearances.tsx index 73ea63b..bb3bf96 100644 --- a/src/app/(tabs)/library/artist/[name]/appearances.tsx +++ b/src/app/(tabs)/library/artist/[name]/appearances.tsx @@ -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'; diff --git a/src/app/(tabs)/library/artist/[name]/songs.tsx b/src/app/(tabs)/library/artist/[name]/songs.tsx index 17738cf..001815c 100644 --- a/src/app/(tabs)/library/artist/[name]/songs.tsx +++ b/src/app/(tabs)/library/artist/[name]/songs.tsx @@ -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'; diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 2a10b9b..4f976e3 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -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(null); const [sortSheetOpen, setSortSheetOpen] = useState(false); + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); const scrollTop = useScrollTopGate(); + const tracksListRef = useRef>(null); + const albumsListRef = useRef>(null); + const artistsListRef = useRef>(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 ( @@ -90,6 +261,7 @@ export default function LibraryScreen() { { + if (selectMode) exitSelection(); scrollTop.setScrollAtTop(true); setViewMode(mode); }} @@ -102,66 +274,87 @@ export default function LibraryScreen() { ) : null} - {viewMode === 'albums' ? ( - album.identity_key} - showsVerticalScrollIndicator={false} - overScrollMode="never" - renderScrollComponent={PullSearchScrollView} - onScroll={scrollTop.onScroll} - scrollEventThrottle={scrollTop.scrollEventThrottle} - renderItem={({ item }) => ( - - - router.push({ - pathname: '/library/album/[key]', - params: { key: item.identity_key }, - }) - } - /> - - )} - /> - ) : null} - - {viewMode === 'artists' ? ( - artist.artist} - showsVerticalScrollIndicator={false} - overScrollMode="never" - renderScrollComponent={PullSearchScrollView} - onScroll={scrollTop.onScroll} - scrollEventThrottle={scrollTop.scrollEventThrottle} - renderItem={({ item }) => ( - - router.push({ - pathname: '/library/artist/[name]', - params: { name: item.artist }, - }) - } - /> - )} - /> - ) : null} - - {viewMode === 'tracks' ? ( - <> - setSortSheetOpen(true)} - accessibilityRole="button" - > - - {TRACK_SORT_LABELS[trackSort]} + {selectMode ? ( + + + {selectedIds.size} selected + + + + Cancel + + + ) : sortable ? ( + setSortSheetOpen(true)} + accessibilityRole="button" + accessibilityLabel={`Sort by ${sortLabel}`} + > + + {sortLabel} + + ) : null} + + + {viewMode === 'albums' ? ( album.identity_key} + showsVerticalScrollIndicator={false} + overScrollMode="never" + renderScrollComponent={PullSearchScrollView} + onScroll={scrollTop.onScroll} + scrollEventThrottle={scrollTop.scrollEventThrottle} + renderItem={({ item }) => ( + + + router.push({ + pathname: '/library/album/[key]', + params: { key: item.identity_key }, + }) + } + /> + + )} + /> + ) : null} + + {viewMode === 'artists' ? ( + artist.artist} + showsVerticalScrollIndicator={false} + overScrollMode="never" + renderScrollComponent={PullSearchScrollView} + onScroll={scrollTop.onScroll} + scrollEventThrottle={scrollTop.scrollEventThrottle} + renderItem={({ item }) => ( + + + router.push({ + pathname: '/library/artist/[name]', + params: { name: item.artist }, + }) + } + /> + + )} + /> + ) : null} + + {viewMode === 'tracks' ? ( + 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 }) => ( 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' ? ( - - ) : null} + {viewMode === 'playlists' ? ( + + ) : null} - {viewMode === 'folders' ? ( - - ) : null} + {viewMode === 'folders' ? ( + + ) : null} + + {railVisible ? ( + + ) : null} + )} + {selectMode && viewMode === 'tracks' ? ( + setPlaylistPickerOpen(true)} + /> + ) : null} + setActionTrack(null)} /> - ({ - key: option, - label: TRACK_SORT_LABELS[option], - selected: option === trackSort, - onPress: () => { - setTrackSort(option); - setSortSheetOpen(false); - }, - }))} - onClose={() => setSortSheetOpen(false)} - /> + {playlistPickerOpen ? ( + setPlaylistPickerOpen(false)} + onAdded={() => { + commitHaptic(); + exitSelection(); + }} + /> + ) : null} + {sortSheetOpen ? ( + setSortSheetOpen(false)}> + + {sortItems.map(({ key, label, selected, onSelect }) => ( + { + onSelect(); + setSortSheetOpen(false); + }} + /> + ))} + + ) : null} ); } @@ -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, + }, }); diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index 09d6c97..47d52af 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -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(null); const [missingEntry, setMissingEntry] = useState(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 ( - - - - - Library - - - - - - {coverHash ? ( - - ) : ( - - )} - - - - {name} - - - {[ - `${playable.length} ${playable.length === 1 ? 'track' : 'tracks'}`, - entries.length > playable.length ? `${entries.length - playable.length} missing` : null, - formatDuration(totalDuration), - ] - .filter(Boolean) - .join(' · ')} - - - startPlayback(0)} - accessibilityRole="button" - > - - - Play - - - - - - Shuffle - - - - - - + String(entry.id)} showsVerticalScrollIndicator={false} + onScroll={onScroll} + scrollEventThrottle={scrollEventThrottle} + contentContainerStyle={{ + paddingTop: insets.top + expandedHeight, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xxl, + }} renderItem={({ item }) => item.track ? ( + + ) : ( + + ) + } + backdropUri={coverHash ? artworkThumbUri(coverHash) : null} + title={name} + heroMeta={{meta}} + disabled={playable.length === 0} + onBack={handleBack} + onPlay={() => startPlayback(0)} + onShuffle={startShuffle} + scrollY={scrollY} + heroFaded={heroFaded} + collapsed={collapsed} + expandedHeight={expandedHeight} + onHeroBlockLayout={onHeroBlockLayout} + /> setActionEntry(null)} extraItems={extraItems} /> - { - void removeFromPlaylist(playlistId, missingEntry.track_path); - setMissingEntry(null); - }, - }, - ] - : [] - } - onClose={() => setMissingEntry(null)} - /> + {missingEntry !== null ? ( + setMissingEntry(null)}> + + {playlistId != null ? ( + { + void removeFromPlaylist(playlistId, missingEntry.track_path); + setMissingEntry(null); + }} + /> + ) : null} + + ) : null} ); } 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', diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx index d436a3e..a7719a0 100644 --- a/src/app/desktop-remote.tsx +++ b/src/app/desktop-remote.tsx @@ -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'; diff --git a/src/app/desktop-remote/scan.tsx b/src/app/desktop-remote/scan.tsx index f9a497e..2c775a3 100644 --- a/src/app/desktop-remote/scan.tsx +++ b/src/app/desktop-remote/scan.tsx @@ -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() { diff --git a/src/app/lastfm/edit.tsx b/src/app/lastfm/edit.tsx index c732a65..eb4877b 100644 --- a/src/app/lastfm/edit.tsx +++ b/src/app/lastfm/edit.tsx @@ -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'; diff --git a/src/app/lastfm/index.tsx b/src/app/lastfm/index.tsx index 53eca28..49e8aa5 100644 --- a/src/app/lastfm/index.tsx +++ b/src/app/lastfm/index.tsx @@ -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'; diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index bc8dd45..6251589 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -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; diff --git a/src/app/recently-played.tsx b/src/app/recently-played.tsx index 335f0d1..b0ea136 100644 --- a/src/app/recently-played.tsx +++ b/src/app/recently-played.tsx @@ -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'; diff --git a/src/app/sources/edit.tsx b/src/app/sources/edit.tsx index 3c2b2ac..7772c82 100644 --- a/src/app/sources/edit.tsx +++ b/src/app/sources/edit.tsx @@ -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'; diff --git a/src/app/sources/index.tsx b/src/app/sources/index.tsx index 88ec852..58f180b 100644 --- a/src/app/sources/index.tsx +++ b/src/app/sources/index.tsx @@ -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'; diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index 9b34c48..1215b96 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -281,6 +281,36 @@ export async function enqueueEnd(track: Track): Promise { 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 { + 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 { + 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 diff --git a/src/components/FormatBadge.tsx b/src/components/FormatBadge.tsx index ab611e6..9696453 100644 --- a/src/components/FormatBadge.tsx +++ b/src/components/FormatBadge.tsx @@ -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; 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 ( + + {parts.join(' · ')} + + ); + } + return ( {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; diff --git a/src/components/MarqueeText.tsx b/src/components/MarqueeText.tsx index a146f2c..4682a64 100644 --- a/src/components/MarqueeText.tsx +++ b/src/components/MarqueeText.tsx @@ -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, { diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 303b712..53ebdb5 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -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'; diff --git a/src/components/OscilloscopeWave.tsx b/src/components/OscilloscopeWave.tsx index 6f60686..547a952 100644 --- a/src/components/OscilloscopeWave.tsx +++ b/src/components/OscilloscopeWave.tsx @@ -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'; diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx index 2b59360..70d3b98 100644 --- a/src/components/Screen.tsx +++ b/src/components/Screen.tsx @@ -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'; diff --git a/src/components/SeekBar.tsx b/src/components/SeekBar.tsx index 376cb53..ac02336 100644 --- a/src/components/SeekBar.tsx +++ b/src/components/SeekBar.tsx @@ -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; diff --git a/src/components/SegmentedControl.tsx b/src/components/SegmentedControl.tsx new file mode 100644 index 0000000..adb8f2c --- /dev/null +++ b/src/components/SegmentedControl.tsx @@ -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 ( + + + {segments.map((segment) => ( + onChange(segment.key)} + /> + ))} + + ); +} + +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 ( + [styles.segment, pressed && styles.segmentPressed]} + onPress={onPress} + accessibilityRole="tab" + accessibilityState={{ selected: focused }} + > + + {label} + + + ); +} + +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; diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 9e7fbc2..14ecc40 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -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'; diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 0f4cffd..bf5c2dc 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -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'; diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 05e0ece..c28894d 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -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; diff --git a/src/components/Text.tsx b/src/components/Text.tsx index f05d865..4108719 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -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'; diff --git a/src/components/Visualizer.tsx b/src/components/Visualizer.tsx index 3bd1284..d0ce99f 100644 --- a/src/components/Visualizer.tsx +++ b/src/components/Visualizer.tsx @@ -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'; diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index 108f51e..a0cd146 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -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'; diff --git a/src/components/eq/BandDetailPanel.tsx b/src/components/eq/BandDetailPanel.tsx index d9c774f..5fa35e8 100644 --- a/src/components/eq/BandDetailPanel.tsx +++ b/src/components/eq/BandDetailPanel.tsx @@ -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; diff --git a/src/components/eq/BandStrip.tsx b/src/components/eq/BandStrip.tsx index 48d6444..d658038 100644 --- a/src/components/eq/BandStrip.tsx +++ b/src/components/eq/BandStrip.tsx @@ -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[]; diff --git a/src/components/eq/EQGraph.tsx b/src/components/eq/EQGraph.tsx index 8bbcae0..fc71189 100644 --- a/src/components/eq/EQGraph.tsx +++ b/src/components/eq/EQGraph.tsx @@ -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; diff --git a/src/components/eq/EQModeSwitcher.tsx b/src/components/eq/EQModeSwitcher.tsx index 5d11090..e69ad32 100644 --- a/src/components/eq/EQModeSwitcher.tsx +++ b/src/components/eq/EQModeSwitcher.tsx @@ -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 ( - - {MODES.map((mode) => { - const active = mode.key === value; - return ( - onChange(mode.key)} - style={[styles.pill, active && styles.pillActive]} - accessibilityRole="button" - accessibilityState={{ selected: active }} - > - - {mode.label} - - - ); - })} - + 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; diff --git a/src/components/eq/EQSlider.tsx b/src/components/eq/EQSlider.tsx index fe52cf1..7099551 100644 --- a/src/components/eq/EQSlider.tsx +++ b/src/components/eq/EQSlider.tsx @@ -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; diff --git a/src/components/eq/EQValueEditSheet.tsx b/src/components/eq/EQValueEditSheet.tsx index 76d4e53..7647047 100644 --- a/src/components/eq/EQValueEditSheet.tsx +++ b/src/components/eq/EQValueEditSheet.tsx @@ -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 { diff --git a/src/components/eq/GraphicEQPanel.tsx b/src/components/eq/GraphicEQPanel.tsx index 9e05c68..6a4a419 100644 --- a/src/components/eq/GraphicEQPanel.tsx +++ b/src/components/eq/GraphicEQPanel.tsx @@ -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[]; diff --git a/src/components/eq/GraphicResponseCurve.tsx b/src/components/eq/GraphicResponseCurve.tsx index f9e3eb5..bb1da4f 100644 --- a/src/components/eq/GraphicResponseCurve.tsx +++ b/src/components/eq/GraphicResponseCurve.tsx @@ -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'; diff --git a/src/components/eq/PresetSheet.tsx b/src/components/eq/PresetSheet.tsx index d2de615..bb5af32 100644 --- a/src/components/eq/PresetSheet.tsx +++ b/src/components/eq/PresetSheet.tsx @@ -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[]; diff --git a/src/components/eq/SavePresetSheet.tsx b/src/components/eq/SavePresetSheet.tsx index a5c7408..b34e84b 100644 --- a/src/components/eq/SavePresetSheet.tsx +++ b/src/components/eq/SavePresetSheet.tsx @@ -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 { diff --git a/src/components/eq/VerticalEQSlider.tsx b/src/components/eq/VerticalEQSlider.tsx index b15d0b5..5908baf 100644 --- a/src/components/eq/VerticalEQSlider.tsx +++ b/src/components/eq/VerticalEQSlider.tsx @@ -3,7 +3,7 @@ import { StyleSheet, View, type GestureResponderEvent, - type LayoutChangeEvent, + type LayoutChangeEvent } from 'react-native'; import { colors, radius } from '@/theme'; diff --git a/src/components/library/AlbumGridItem.tsx b/src/components/library/AlbumGridItem.tsx index 8db6be8..a89c760 100644 --- a/src/components/library/AlbumGridItem.tsx +++ b/src/components/library/AlbumGridItem.tsx @@ -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} /> ) : ( diff --git a/src/components/library/AlbumRow.tsx b/src/components/library/AlbumRow.tsx index 02c9f31..68e288b 100644 --- a/src/components/library/AlbumRow.tsx +++ b/src/components/library/AlbumRow.tsx @@ -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'; diff --git a/src/components/library/AlphabetRail.tsx b/src/components/library/AlphabetRail.tsx new file mode 100644 index 0000000..0c30216 --- /dev/null +++ b/src/components/library/AlphabetRail.tsx @@ -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; + 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(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 ( + + {scrubLetter ? ( + + + {scrubLetter} + + + ) : null} + + + {RAIL_LETTERS.map((letter) => { + const present = activeLetters.has(letter); + const scrubbing = letter === scrubLetter; + return ( + + + {letter} + + + ); + })} + + + + ); +} + +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, + }, +}); diff --git a/src/components/library/ArtistGridItem.tsx b/src/components/library/ArtistGridItem.tsx new file mode 100644 index 0000000..23d9f53 --- /dev/null +++ b/src/components/library/ArtistGridItem.tsx @@ -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 ( + + + {hashes.length === 0 ? ( + + ) : useMosaic ? ( + hashes.map((hash) => ( + + )) + ) : ( + + )} + + + {artist.artist} + + + {albums} · {tracks} + + + ); +} + +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, + }, +}); diff --git a/src/components/library/ArtistRow.tsx b/src/components/library/ArtistRow.tsx index 2af79ed..3124d04 100644 --- a/src/components/library/ArtistRow.tsx +++ b/src/components/library/ArtistRow.tsx @@ -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'; diff --git a/src/components/library/CollapsingDetail.tsx b/src/components/library/CollapsingDetail.tsx new file mode 100644 index 0000000..c2eb01c --- /dev/null +++ b/src/components/library/CollapsingDetail.tsx @@ -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) => { + 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 ( + setWidth(e.nativeEvent.layout.width)}> + {width > 0 ? ( + + + + + + ) : null} + + ); +} + +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; + 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 ( + + {/* Blurred wash (fixed tall, clipped by the shrinking container) + fade at the bottom edge. */} + + {backdropUri ? ( + + ) : ( + + )} + + + + + + {backdropUri ? ( + <> + + + + ) : ( + + )} + + + + + {title} + + {heroMeta} + + + + + Play + + + + + + Shuffle + + + + + + + + + + Library + + + + {title} + + + + + + + + + + + + {/* Rendered last so the large art sits on top of the header text until it tucks away. */} + + {artwork} + + + ); +} + +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', + }, +}); diff --git a/src/components/library/EmptyLibrary.tsx b/src/components/library/EmptyLibrary.tsx index 72cdf18..6a2c574 100644 --- a/src/components/library/EmptyLibrary.tsx +++ b/src/components/library/EmptyLibrary.tsx @@ -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(); diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index a9ba905..fba0e1e 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -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; 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 ( [styles.folderRow, pressed && styles.rowPressed]} onPress={() => onToggle(node.id)} + onLongPress={() => onOpenActions(node)} accessibilityRole="button" accessibilityState={{ expanded: isExpanded }} > @@ -70,6 +101,24 @@ function FolderRow({ {node.totalTrackCount} + [styles.folderButton, pressed && styles.folderButtonPressed]} + onPress={play} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={`Play ${node.name}`} + > + + + [styles.folderButton, pressed && styles.folderButtonPressed]} + onPress={shuffle} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={`Shuffle ${node.name}`} + > + + ); } @@ -95,7 +144,11 @@ function FolderTrackRow({ return ( [ + 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>(() => new Set()); const [actionTrack, setActionTrack] = useState(null); + const [actionFolder, setActionFolder] = useState(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' ? ( - + ) : ( setActionTrack(null)} /> + {actionFolder ? ( + setActionFolder(null)}> + + { + playFolder(actionFolder); + setActionFolder(null); + }} + /> + { + shuffleFolder(actionFolder); + setActionFolder(null); + }} + /> + { + playFolderNext(actionFolder); + setActionFolder(null); + }} + /> + { + queueFolder(actionFolder); + setActionFolder(null); + }} + /> + + ) : 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, diff --git a/src/components/library/PlaylistRow.tsx b/src/components/library/PlaylistRow.tsx index addda15..ec7521f 100644 --- a/src/components/library/PlaylistRow.tsx +++ b/src/components/library/PlaylistRow.tsx @@ -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({ diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx index 32a185a..c7223c2 100644 --- a/src/components/library/PlaylistsView.tsx +++ b/src/components/library/PlaylistsView.tsx @@ -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={ + + + + No playlists yet. Create one or import an M3U below. + + + } ListFooterComponent={ - setMenuFor(null)} - /> + {menuFor !== null ? ( + setMenuFor(null)}> + + {menuItems.map(({ key, ...item }) => ( + + ))} + + ) : null} 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 ( + + + + + + ); +} + +function BarButton({ + icon, + label, + accessibilityLabel, + disabled, + onPress, +}: { + icon: keyof typeof Ionicons.glyphMap; + label: string; + accessibilityLabel: string; + disabled: boolean; + onPress: () => void; +}) { + return ( + [ + styles.button, + pressed && styles.buttonPressed, + disabled && styles.buttonDisabled, + ]} + onPress={onPress} + disabled={disabled} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + > + + + {label} + + + ); +} + +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, + }, +}); diff --git a/src/components/library/TrackActionsSheet.tsx b/src/components/library/TrackActionsSheet.tsx index b2c9d0c..5f46362 100644 --- a/src/components/library/TrackActionsSheet.tsx +++ b/src/components/library/TrackActionsSheet.tsx @@ -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 ( - - - {initialStep === 'menu' ? ( - setStep('menu')} /> - ) : null} - {playlists.length === 0 ? ( - - No playlists yet. - - ) : null} - {pickItems.map(({ key, ...item }) => ( - - ))} - - ); - } - - if (step === 'newPlaylist') { - return ( - - - - - setStep('pickPlaylist')}> - - Back - - - - - Create - - - - + setStep('menu') : undefined} + /> ); } return ( - + {menuItems.map(({ key, ...item }) => ( ))} @@ -205,66 +136,3 @@ function TrackActionsSheetInner({ ); } - -function SheetTitle({ title, subtitle }: { title: string; subtitle?: string }) { - return ( - - - {title} - - {subtitle ? ( - - {subtitle} - - ) : null} - - ); -} - -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, - }, -}); diff --git a/src/components/library/TrackRow.tsx b/src/components/library/TrackRow.tsx index f63ad83..b77844a 100644 --- a/src/components/library/TrackRow.tsx +++ b/src/components/library/TrackRow.tsx @@ -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 = ( + {selectionMode ? ( + + ) : null} {thumbUri ? ( - + {formatDuration(track.duration)} - {onOpenActions ? ( + {onOpenActions && !selectionMode ? ( [styles.actionsButton, pressed && styles.actionsButtonPressed]} onPress={openActions} @@ -126,7 +153,7 @@ export function TrackRow({ ); - if (!swipeToQueue) return row; + if (!swipeToQueue || selectionMode) return row; return ( void; }) { return ( - - {MODES.map((mode) => { - const active = mode.key === value; - return ( - onChange(mode.key)} - style={[styles.pill, active && styles.pillActive]} - accessibilityRole="button" - accessibilityState={{ selected: active }} - > - - {mode.label} - - - ); - })} - + 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, - }, -}); diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index 8e6dfcb..7baf8eb 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -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'; diff --git a/src/components/search/PullSearchGesture.tsx b/src/components/search/PullSearchGesture.tsx index 06de2dd..fc94c7d 100644 --- a/src/components/search/PullSearchGesture.tsx +++ b/src/components/search/PullSearchGesture.tsx @@ -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 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 ( + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + ); +} + 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', diff --git a/src/components/sheets/PlaylistPickerSheet.tsx b/src/components/sheets/PlaylistPickerSheet.tsx new file mode 100644 index 0000000..e0e5b74 --- /dev/null +++ b/src/components/sheets/PlaylistPickerSheet.tsx @@ -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 ( + + + + + setStep('pick')}> + + Back + + + + + Create + + + + + ); + } + + return ( + + + {onBackToMenu ? ( + + ) : null} + {playlists.length === 0 ? ( + + No playlists yet. + + ) : null} + {playlists.map((playlist) => ( + addToExisting(playlist.id)} + /> + ))} + setStep('create')} /> + + ); +} + +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, + }, +}); diff --git a/src/components/sheets/TextPromptModal.tsx b/src/components/sheets/TextPromptModal.tsx index 6b6b094..1736299 100644 --- a/src/components/sheets/TextPromptModal.tsx +++ b/src/components/sheets/TextPromptModal.tsx @@ -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; diff --git a/src/lib/albumSort.ts b/src/lib/albumSort.ts new file mode 100644 index 0000000..5139848 --- /dev/null +++ b/src/lib/albumSort.ts @@ -0,0 +1,30 @@ +import type { Album } from '@/types/library'; + +export type AlbumSort = 'artist' | 'name' | 'recently_added' | 'year'; + +export const ALBUM_SORT_LABELS: Record = { + 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); + }); + } +} diff --git a/src/lib/artistSort.ts b/src/lib/artistSort.ts new file mode 100644 index 0000000..843e285 --- /dev/null +++ b/src/lib/artistSort.ts @@ -0,0 +1,20 @@ +import type { Artist } from '@/types/library'; + +export type ArtistSort = 'name' | 'track_count'; + +export const ARTIST_SORT_LABELS: Record = { + 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) + ); + } +} diff --git a/src/lib/letterIndex.ts b/src/lib/letterIndex.ts new file mode 100644 index 0000000..8ac6d39 --- /dev/null +++ b/src/lib/letterIndex.ts @@ -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( + items: readonly T[], + keyOf: (item: T) => string +): LetterIndexEntry[] { + const firstByLetter = new Map(); + 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; +} diff --git a/src/library/artistGrouping.ts b/src/library/artistGrouping.ts index b1a25c8..da1d03e 100644 --- a/src/library/artistGrouping.ts +++ b/src/library/artistGrouping.ts @@ -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; + /** First artwork hash seen per album — feeds the grid's 2x2 mosaic. */ + albumArtwork: Map; } /** @@ -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' })); } diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 62233f5..011f3a4 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -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; removeFolder: (folderId: number) => Promise; rescan: () => Promise; @@ -93,6 +133,8 @@ export const useLibraryStore = create((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((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((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 })), diff --git a/src/types/library.ts b/src/types/library.ts index 426b9b5..5b30439 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -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[]; }