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