massive UI/UX overhaul

This commit is contained in:
Boof2015
2026-07-03 23:56:34 -04:00
parent 9ce825b673
commit 61e6147d3e
71 changed files with 2806 additions and 953 deletions
+13 -3
View File
@@ -1,5 +1,11 @@
import { useCallback, useState } from 'react';
import { InteractionManager, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
import {
InteractionManager,
Pressable,
StyleSheet,
View,
useWindowDimensions
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -17,7 +23,11 @@ import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel';
import { PresetSheet } from '@/components/eq/PresetSheet';
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { isWideWindow } from '@/theme/adaptive';
import { useEQStore } from '@/stores/eqStore';
import { useScopeActive } from '@/scope/scopeStore';
@@ -31,7 +41,7 @@ import {
EQ_MIN_FREQUENCY,
EQ_MIN_PREAMP_DB,
EQ_MIN_Q,
isPassEQBandType,
isPassEQBandType
} from '@/audio/eq';
import { parseAutoEQ } from '@/audio/autoEQParser';
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
+15 -4
View File
@@ -1,5 +1,11 @@
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import {
Pressable,
ScrollView,
StyleSheet,
View,
type LayoutChangeEvent
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
@@ -14,9 +20,14 @@ import { ScanProgress } from '@/components/library/ScanProgress';
import {
PullSearchGesture,
PullSearchScrollView,
useScrollTopGate,
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import { colors, fonts, radius, spacing } from '@/theme';
import {
colors,
fonts,
radius,
spacing
} from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -26,7 +37,7 @@ import {
shuffleTracks,
skipToNext,
skipToPrevious,
togglePlay,
togglePlay
} from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { albumArtworkSource } from '@/library/artwork';
+117 -128
View File
@@ -1,30 +1,48 @@
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, radius, spacing } from '@/theme';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { albumArtworkSource } from '@/library/artwork';
import { albumArtworkSource, artworkThumbUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
import type { DbTrack } from '@/types/library';
type AlbumRow =
| { kind: 'track'; track: DbTrack; index: number }
| { kind: 'disc'; disc: number };
function DiscHeader({ disc }: { disc: number }) {
return (
<View style={styles.discHeader}>
<Ionicons name="disc-outline" size={16} color={colors.textSecondary} />
<Text variant="heading">Disc {disc}</Text>
</View>
);
}
export default function AlbumScreen() {
const { key, from } = useLocalSearchParams<{ key: string; from?: string }>();
const albums = useLibraryStore((s) => s.albums);
const allTracks = useLibraryStore((s) => s.tracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const handleBack = useLibraryDetailBack(from);
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
const album = albums.find((entry) => entry.identity_key === key);
// Store tracks are ordered artist/album/disc/track, so the filtered slice
@@ -37,150 +55,121 @@ export default function AlbumScreen() {
const totalDuration = tracks.reduce((sum, track) => sum + track.duration, 0);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
// Interleave "Disc N" headers only when the album spans multiple discs;
// untagged tracks fall back to disc 1. Track rows keep their index into the
// flat `tracks` array so tap-to-play stays correct.
const albumItems = useMemo<AlbumRow[]>(() => {
const maxDisc = tracks.reduce((m, track) => Math.max(m, track.disc_number ?? 1), 1);
if (maxDisc <= 1) {
return tracks.map((track, index) => ({ kind: 'track', track, index }));
}
const rows: AlbumRow[] = [];
let lastDisc: number | null = null;
tracks.forEach((track, index) => {
const disc = track.disc_number ?? 1;
if (disc !== lastDisc) {
rows.push({ kind: 'disc', disc });
lastDisc = disc;
}
rows.push({ kind: 'track', track, index });
});
return rows;
}, [tracks]);
const playFrom = (index: number) => {
void playTracks(tracks.map(dbTrackToTrack), index);
};
const artSource = album ? albumArtworkSource(album) : null;
// Blur the cached thumbnail, not the full-res cover (remote albums have no
// local thumb — their server URL is already sized reasonably).
const backdropUri = album?.artwork_hash ? artworkThumbUri(album.artwork_hash) : artSource;
const meta = [
album?.year ? String(album.year) : null,
`${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ');
return (
<Screen>
<Pressable style={styles.back} onPress={handleBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Library
</Text>
</Pressable>
<View style={styles.header}>
<View style={styles.art}>
{album && albumArtworkSource(album) ? (
<Image
source={{ uri: albumArtworkSource(album)! }}
style={styles.artImage}
contentFit="cover"
transition={120}
/>
) : (
<AstraLogo size={42} />
)}
</View>
<View style={styles.headerMeta}>
<Text variant="heading" numberOfLines={2}>
{album?.album ?? 'Album'}
</Text>
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{album?.artist ?? ''}
</Text>
<Text variant="label">
{[
album?.year ? String(album.year) : null,
`${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ')}
</Text>
<View style={styles.buttons}>
<Pressable style={styles.playButton} onPress={() => playFrom(0)} accessibilityRole="button">
<Ionicons name="play" size={16} color={colors.bgPrimary} />
<Text variant="body" style={styles.playLabel}>
Play
</Text>
</Pressable>
<Pressable
style={styles.shuffleButton}
onPress={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
</View>
</View>
<Screen padded={false} style={styles.screen}>
<FlashList
data={tracks}
keyExtractor={(track) => String(track.id)}
data={albumItems}
keyExtractor={(item) => (item.kind === 'disc' ? `disc-${item.disc}` : String(item.track.id))}
getItemType={(item) => item.kind}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
showArtist={false}
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
onOpenActions={() => setActionTrack(item)}
/>
)}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: insets.top + expandedHeight,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxl,
}}
renderItem={({ item }) =>
item.kind === 'disc' ? (
<DiscHeader disc={item.disc} />
) : (
<TrackRow
track={item.track}
showArtist={false}
active={item.track.path === currentPath}
onPress={() => playFrom(item.index)}
onLongPress={() => setActionTrack(item.track)}
onOpenActions={() => setActionTrack(item.track)}
/>
)
}
/>
<CollapsingHeader
artwork={
artSource ? (
<Image source={{ uri: artSource }} style={styles.artFill} contentFit="cover" transition={150} />
) : (
<AstraLogo size={56} />
)
}
backdropUri={backdropUri}
title={album?.album ?? 'Album'}
heroMeta={
<>
{album?.artist ? (
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{album.artist}
</Text>
) : null}
<Text variant="label">{meta}</Text>
</>
}
disabled={tracks.length === 0}
onBack={handleBack}
onPlay={() => playFrom(0)}
onShuffle={() => void shuffleTracks(tracks.map(dbTrackToTrack))}
scrollY={scrollY}
heroFaded={heroFaded}
collapsed={collapsed}
expandedHeight={expandedHeight}
onHeroBlockLayout={onHeroBlockLayout}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.md,
alignSelf: 'flex-start',
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
},
header: {
flexDirection: 'row',
gap: spacing.lg,
marginBottom: spacing.lg,
},
art: {
width: 128,
height: 128,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
artFill: {
width: '100%',
height: '100%',
},
headerMeta: {
flex: 1,
justifyContent: 'center',
gap: spacing.xs,
},
buttons: {
discHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
paddingTop: spacing.xl,
paddingBottom: spacing.md,
},
});
+89 -188
View File
@@ -1,22 +1,42 @@
import { useMemo, useState, type ComponentProps } from 'react';
import { Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native';
import {
useMemo,
useState,
type ComponentProps
} from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
View
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, fontSize, radius, spacing } from '@/theme';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import {
colors,
fontSize,
radius,
spacing
} from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { buildArtistDetail, type ArtistAlbum, type ArtistDetail } from '@/library/artistDetail';
import { artworkThumbUri, artworkUri } from '@/library/artwork';
import {
buildArtistDetail,
type ArtistAlbum,
type ArtistDetail
} from '@/library/artistDetail';
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
import type { DbTrack } from '@/types/library';
@@ -28,7 +48,6 @@ const ALBUM_PREVIEW_LIMIT = 8;
const APPEARANCE_PREVIEW_LIMIT = 5;
type ArtistPageItem =
| { key: 'hero'; type: 'hero' }
| {
key: string;
type: 'section';
@@ -44,7 +63,9 @@ export default function ArtistScreen() {
const router = useRouter();
const { name = 'Artist', from } = useLocalSearchParams<{ name: string; from?: string }>();
const handleBack = useLibraryDetailBack(from);
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
const allTracks = useLibraryStore((s) => s.tracks);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
@@ -77,16 +98,6 @@ export default function ArtistScreen() {
const renderItem = ({ item }: { item: ArtistPageItem }) => {
switch (item.type) {
case 'hero':
return (
<ArtistHero
artistName={name}
detail={detail}
compact={width < 380}
onPlay={playArtist}
onShuffle={shuffleArtist}
/>
);
case 'section': {
const target = item.target;
return (
@@ -135,30 +146,56 @@ export default function ArtistScreen() {
}
};
return (
<Screen>
<Pressable style={styles.back} onPress={handleBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Library
</Text>
</Pressable>
const backdropHash = detail.artworkHashes[0] ?? null;
const disabled = detail.playbackTracks.length === 0;
return (
<Screen padded={false} style={styles.screen}>
<FlashList
data={listItems}
keyExtractor={(item) => item.key}
showsVerticalScrollIndicator={false}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: insets.top + expandedHeight,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxl,
}}
/>
<CollapsingHeader
artwork={artistArtwork(detail.artworkHashes)}
backdropUri={backdropHash ? artworkThumbUri(backdropHash) : null}
title={name}
heroMeta={
<View style={styles.stats}>
{detail.albums.length > 0 ? (
<StatChip icon="albums-outline" label={formatCount(detail.albums.length, 'album')} />
) : null}
<StatChip icon="musical-notes-outline" label={formatCount(detail.tracks.length, 'track')} />
{detail.totalDuration > 0 ? (
<StatChip icon="time-outline" label={formatRuntime(detail.totalDuration)} />
) : null}
</View>
}
disabled={disabled}
onBack={handleBack}
onPlay={playArtist}
onShuffle={shuffleArtist}
scrollY={scrollY}
heroFaded={heroFaded}
collapsed={collapsed}
expandedHeight={expandedHeight}
onHeroBlockLayout={onHeroBlockLayout}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
function buildListItems(detail: ArtistDetail): ArtistPageItem[] {
const items: ArtistPageItem[] = [{ key: 'hero', type: 'hero' }];
const items: ArtistPageItem[] = [];
if (detail.tracks.length === 0) {
items.push({ key: 'empty', type: 'empty' });
@@ -209,83 +246,18 @@ function buildListItems(detail: ArtistDetail): ArtistPageItem[] {
return items;
}
function ArtistHero({
artistName,
detail,
compact,
onPlay,
onShuffle,
}: {
artistName: string;
detail: ArtistDetail;
compact: boolean;
onPlay: () => void;
onShuffle: () => void;
}) {
const disabled = detail.playbackTracks.length === 0;
return (
<View style={styles.hero}>
<ArtistArtwork hashes={detail.artworkHashes} compact={compact} />
<View style={styles.heroMeta}>
<Text
variant="title"
numberOfLines={3}
adjustsFontSizeToFit
minimumFontScale={0.78}
style={styles.artistName}
>
{artistName}
</Text>
<View style={styles.stats}>
{detail.albums.length > 0 ? (
<StatChip icon="albums-outline" label={formatCount(detail.albums.length, 'album')} />
) : null}
<StatChip icon="musical-notes-outline" label={formatCount(detail.tracks.length, 'track')} />
{detail.totalDuration > 0 ? (
<StatChip icon="time-outline" label={formatRuntime(detail.totalDuration)} />
) : null}
</View>
</View>
<View style={styles.actionRow}>
<Pressable
style={[styles.actionButton, styles.primaryAction, disabled && styles.disabledAction]}
onPress={onPlay}
disabled={disabled}
accessibilityRole="button"
>
<Ionicons name="play" size={17} color={colors.bgPrimary} />
<Text variant="body" style={styles.primaryActionText}>
Play
</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.secondaryAction, disabled && styles.disabledAction]}
onPress={onShuffle}
disabled={disabled}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={17} color={colors.accent} />
<Text variant="body" color={colors.accent} style={styles.secondaryActionText}>
Shuffle
</Text>
</Pressable>
</View>
</View>
);
}
function ArtistArtwork({ hashes, compact }: { hashes: string[]; compact: boolean }) {
/** Inner artwork for the collapsing header: 2x2 album mosaic, single cover, or fallback. */
function artistArtwork(hashes: string[]) {
const useMosaic = hashes.length >= 4;
const displayHashes = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1);
const display = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1);
return (
<View style={[styles.heroArt, compact && styles.heroArtCompact]}>
{displayHashes.length === 0 ? (
<Ionicons name="person" size={compact ? 42 : 52} color={colors.textTertiary} />
) : useMosaic ? (
displayHashes.map((hash) => (
if (display.length === 0) {
return <Ionicons name="person" size={60} color={colors.textTertiary} />;
}
if (useMosaic) {
return (
<View style={styles.mosaic}>
{display.map((hash) => (
<Image
key={hash}
source={{ uri: artworkUri(hash) }}
@@ -293,16 +265,12 @@ function ArtistArtwork({ hashes, compact }: { hashes: string[]; compact: boolean
contentFit="cover"
transition={120}
/>
))
) : (
<Image
source={{ uri: artworkUri(displayHashes[0]) }}
style={styles.heroArtImage}
contentFit="cover"
transition={120}
/>
)}
</View>
))}
</View>
);
}
return (
<Image source={{ uri: artworkUri(display[0]) }} style={styles.artFill} contentFit="cover" transition={120} />
);
}
@@ -413,57 +381,23 @@ function trackSubtitle(track: DbTrack, section: 'appearances' | 'songs'): string
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.sm,
alignSelf: 'flex-start',
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
},
listContent: {
paddingBottom: spacing.xxl,
},
hero: {
alignItems: 'center',
gap: spacing.lg,
paddingTop: spacing.sm,
paddingBottom: spacing.lg,
},
heroArt: {
width: 168,
height: 168,
borderRadius: radius.lg,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
flexDirection: 'row',
flexWrap: 'wrap',
},
heroArtCompact: {
width: 144,
height: 144,
},
heroArtImage: {
mosaic: {
width: '100%',
height: '100%',
flexDirection: 'row',
flexWrap: 'wrap',
},
mosaicTile: {
width: '50%',
height: '50%',
},
heroMeta: {
artFill: {
width: '100%',
alignItems: 'center',
gap: spacing.md,
},
artistName: {
maxWidth: '100%',
textAlign: 'center',
lineHeight: fontSize.xxl * 1.08,
height: '100%',
},
stats: {
flexDirection: 'row',
@@ -487,39 +421,6 @@ const styles = StyleSheet.create({
statLabel: {
maxWidth: 180,
},
actionRow: {
width: '100%',
flexDirection: 'row',
gap: spacing.sm,
},
actionButton: {
flex: 1,
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
},
primaryAction: {
backgroundColor: colors.accent,
},
secondaryAction: {
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.glassBg,
},
disabledAction: {
opacity: 0.45,
},
primaryActionText: {
color: colors.bgPrimary,
fontWeight: '600',
},
secondaryActionText: {
fontWeight: '600',
},
sectionHeader: {
minHeight: 36,
flexDirection: 'row',
@@ -1,5 +1,9 @@
import { useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -1,5 +1,9 @@
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -1,5 +1,9 @@
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useRouter } from 'expo-router';
+331 -97
View File
@@ -1,35 +1,78 @@
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import {
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {
BackHandler,
View,
Pressable,
StyleSheet
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { FlashList, type FlashListRef } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { ArtistGridItem } from '@/components/library/ArtistGridItem';
import { TrackRow } from '@/components/library/TrackRow';
import { ArtistRow } from '@/components/library/ArtistRow';
import { FoldersView } from '@/components/library/FoldersView';
import { PlaylistsView } from '@/components/library/PlaylistsView';
import { ScanProgress } from '@/components/library/ScanProgress';
import { EmptyLibrary } from '@/components/library/EmptyLibrary';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import { AlphabetRail } from '@/components/library/AlphabetRail';
import { SelectionActionBar } from '@/components/library/SelectionActionBar';
import {
AppSheet,
AppSheetItem,
AppSheetSection
} from '@/components/sheets/AppSheet';
import { PlaylistPickerSheet } from '@/components/sheets/PlaylistPickerSheet';
import {
PullSearchGesture,
PullSearchScrollView,
useScrollTopGate,
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
import { playTracks } from '@/audio/playbackController';
import {
enqueueEndMany,
enqueueTopMany,
playTracks
} from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { sortTracks, TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
import type { DbTrack } from '@/types/library';
import { commitHaptic, dragArmHaptic } from '@/lib/haptics';
import {
sortTracks,
TRACK_SORT_LABELS,
type TrackSort
} from '@/lib/trackSort';
import {
sortAlbums,
ALBUM_SORT_LABELS,
type AlbumSort
} from '@/lib/albumSort';
import {
sortArtists,
ARTIST_SORT_LABELS,
type ArtistSort
} from '@/lib/artistSort';
import { buildLetterIndex, resolveJumpIndex } from '@/lib/letterIndex';
import type {
Album,
Artist,
DbTrack
} from '@/types/library';
const SORT_OPTIONS: TrackSort[] = ['artist', 'title', 'recently_added', 'duration'];
const TRACK_SORT_OPTIONS: TrackSort[] = ['artist', 'title', 'recently_added', 'duration'];
const ALBUM_SORT_OPTIONS: AlbumSort[] = ['artist', 'name', 'recently_added', 'year'];
const ARTIST_SORT_OPTIONS: ArtistSort[] = ['name', 'track_count'];
export default function LibraryScreen() {
const router = useRouter();
@@ -41,6 +84,10 @@ export default function LibraryScreen() {
const folders = useLibraryStore((s) => s.folders);
const trackSort = useLibraryStore((s) => s.trackSort);
const setTrackSort = useLibraryStore((s) => s.setTrackSort);
const albumSort = useLibraryStore((s) => s.albumSort);
const setAlbumSort = useLibraryStore((s) => s.setAlbumSort);
const artistSort = useLibraryStore((s) => s.artistSort);
const setArtistSort = useLibraryStore((s) => s.setArtistSort);
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
@@ -48,14 +95,29 @@ export default function LibraryScreen() {
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const scrollTop = useScrollTopGate();
const tracksListRef = useRef<FlashListRef<DbTrack>>(null);
const albumsListRef = useRef<FlashListRef<Album>>(null);
const artistsListRef = useRef<FlashListRef<Artist>>(null);
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning;
const sortedTracks = useMemo(
() => (viewMode === 'tracks' ? sortTracks(tracks, trackSort) : []),
[trackSort, tracks, viewMode]
);
const sortedAlbums = useMemo(
() => (viewMode === 'albums' ? sortAlbums(albums, albumSort) : []),
[albumSort, albums, viewMode]
);
const sortedArtists = useMemo(
() => (viewMode === 'artists' ? sortArtists(artists, artistSort) : []),
[artistSort, artists, viewMode]
);
// Tap index is within sortedTracks so the tapped row is the track that plays.
const playAllFrom = (index: number) => {
@@ -63,6 +125,115 @@ export default function LibraryScreen() {
};
const openSearch = () => openQuickSearch();
// A-Z rail: only for sorts where a letter jump is meaningful.
const letterIndex = useMemo(() => {
if (viewMode === 'tracks' && (trackSort === 'artist' || trackSort === 'title')) {
return buildLetterIndex(sortedTracks, (t) => (trackSort === 'title' ? t.title : t.artist));
}
if (viewMode === 'albums' && (albumSort === 'artist' || albumSort === 'name')) {
return buildLetterIndex(sortedAlbums, (a) => (albumSort === 'name' ? a.album : a.artist));
}
if (viewMode === 'artists' && artistSort === 'name') {
return buildLetterIndex(sortedArtists, (a) => a.artist);
}
return [];
}, [albumSort, artistSort, sortedAlbums, sortedArtists, sortedTracks, trackSort, viewMode]);
const railVisible = letterIndex.length > 1;
const railLetters = useMemo(
() => new Set(letterIndex.map((entry) => entry.letter)),
[letterIndex]
);
const jumpToLetter = (letter: string) => {
const index = resolveJumpIndex(letterIndex, letter);
if (index == null) return;
// Fire-and-forget: letter-change granularity already throttles the calls.
if (viewMode === 'tracks') void tracksListRef.current?.scrollToIndex({ index, animated: false });
else if (viewMode === 'albums') void albumsListRef.current?.scrollToIndex({ index, animated: false });
else if (viewMode === 'artists') void artistsListRef.current?.scrollToIndex({ index, animated: false });
};
// Multi-select (tracks view): long-press arms it, batch actions live in the
// bottom bar, selection order follows the current display order.
const enterSelection = (track: DbTrack) => {
dragArmHaptic();
setSelectMode(true);
setSelectedIds(new Set([track.id]));
};
const toggleSelected = (id: number) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const exitSelection = () => {
setSelectMode(false);
setSelectedIds(new Set());
setPlaylistPickerOpen(false);
};
useEffect(() => {
if (!selectMode) return;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
exitSelection();
return true;
});
return () => sub.remove();
}, [selectMode]);
const selectedDbTracks = () => sortedTracks.filter((track) => selectedIds.has(track.id));
const batchPlayNext = () => {
const tracks = selectedDbTracks().map(dbTrackToTrack);
commitHaptic();
exitSelection();
void enqueueTopMany(tracks);
};
const batchAddToQueue = () => {
const tracks = selectedDbTracks().map(dbTrackToTrack);
commitHaptic();
exitSelection();
void enqueueEndMany(tracks);
};
// One sort trigger + sheet across the three sortable views.
const sortable = viewMode === 'tracks' || viewMode === 'albums' || viewMode === 'artists';
const sortLabel =
viewMode === 'tracks'
? TRACK_SORT_LABELS[trackSort]
: viewMode === 'albums'
? ALBUM_SORT_LABELS[albumSort]
: ARTIST_SORT_LABELS[artistSort];
const sortSheetLabel =
viewMode === 'tracks' ? 'SORT TRACKS BY' : viewMode === 'albums' ? 'SORT ALBUMS BY' : 'SORT ARTISTS BY';
const sortItems =
viewMode === 'tracks'
? TRACK_SORT_OPTIONS.map((option) => ({
key: option,
label: TRACK_SORT_LABELS[option],
selected: option === trackSort,
onSelect: () => setTrackSort(option),
}))
: viewMode === 'albums'
? ALBUM_SORT_OPTIONS.map((option) => ({
key: option,
label: ALBUM_SORT_LABELS[option],
selected: option === albumSort,
onSelect: () => setAlbumSort(option),
}))
: ARTIST_SORT_OPTIONS.map((option) => ({
key: option,
label: ARTIST_SORT_LABELS[option],
selected: option === artistSort,
onSelect: () => setArtistSort(option),
}));
return (
<Screen>
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
@@ -90,6 +261,7 @@ export default function LibraryScreen() {
<ViewModeSwitcher
value={viewMode}
onChange={(mode) => {
if (selectMode) exitSelection();
scrollTop.setScrollAtTop(true);
setViewMode(mode);
}}
@@ -102,66 +274,87 @@ export default function LibraryScreen() {
</Text>
) : null}
{viewMode === 'albums' ? (
<FlashList
data={albums}
numColumns={2}
keyExtractor={(album) => album.identity_key}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<View style={styles.gridCell}>
<AlbumGridItem
album={item}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.identity_key },
})
}
/>
</View>
)}
/>
) : null}
{viewMode === 'artists' ? (
<FlashList
data={artists}
keyExtractor={(artist) => artist.artist}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<ArtistRow
artist={item}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist },
})
}
/>
)}
/>
) : null}
{viewMode === 'tracks' ? (
<>
<Pressable
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
accessibilityRole="button"
>
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
<Text variant="label">{TRACK_SORT_LABELS[trackSort]}</Text>
{selectMode ? (
<View style={styles.selectionHeader}>
<Text variant="label">
{selectedIds.size} selected
</Text>
<Pressable onPress={exitSelection} hitSlop={8} accessibilityRole="button">
<Text variant="label" color={colors.accentText}>
Cancel
</Text>
</Pressable>
</View>
) : sortable ? (
<Pressable
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
accessibilityRole="button"
accessibilityLabel={`Sort by ${sortLabel}`}
>
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
<Text variant="label">{sortLabel}</Text>
</Pressable>
) : null}
<View style={styles.listArea}>
{viewMode === 'albums' ? (
<FlashList
ref={albumsListRef}
data={sortedAlbums}
numColumns={3}
keyExtractor={(album) => album.identity_key}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<View style={styles.gridCell}>
<AlbumGridItem
album={item}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.identity_key },
})
}
/>
</View>
)}
/>
) : null}
{viewMode === 'artists' ? (
<FlashList
ref={artistsListRef}
data={sortedArtists}
numColumns={3}
keyExtractor={(artist) => artist.artist}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<View style={styles.gridCell}>
<ArtistGridItem
artist={item}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist },
})
}
/>
</View>
)}
/>
) : null}
{viewMode === 'tracks' ? (
<FlashList
ref={tracksListRef}
data={sortedTracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
@@ -169,51 +362,81 @@ export default function LibraryScreen() {
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
extraData={selectMode ? selectedIds : undefined}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
onLongPress={() => setActionTrack(item)}
onLongPress={() => enterSelection(item)}
onOpenActions={() => setActionTrack(item)}
selectionMode={selectMode}
selected={selectedIds.has(item.id)}
onToggleSelect={() => toggleSelected(item.id)}
/>
)}
/>
</>
) : null}
) : null}
{viewMode === 'playlists' ? (
<PlaylistsView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{viewMode === 'playlists' ? (
<PlaylistsView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{viewMode === 'folders' ? (
<FoldersView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{viewMode === 'folders' ? (
<FoldersView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{railVisible ? (
<AlphabetRail activeLetters={railLetters} onJumpToLetter={jumpToLetter} />
) : null}
</View>
</>
)}
</PullSearchGesture>
{selectMode && viewMode === 'tracks' ? (
<SelectionActionBar
count={selectedIds.size}
onPlayNext={batchPlayNext}
onAddToQueue={batchAddToQueue}
onAddToPlaylist={() => setPlaylistPickerOpen(true)}
/>
) : null}
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
<ActionSheet
visible={sortSheetOpen}
title="Sort tracks by"
items={SORT_OPTIONS.map((option) => ({
key: option,
label: TRACK_SORT_LABELS[option],
selected: option === trackSort,
onPress: () => {
setTrackSort(option);
setSortSheetOpen(false);
},
}))}
onClose={() => setSortSheetOpen(false)}
/>
{playlistPickerOpen ? (
<PlaylistPickerSheet
tracks={selectedDbTracks()}
subtitle={`${selectedIds.size} ${selectedIds.size === 1 ? 'track' : 'tracks'}`}
onClose={() => setPlaylistPickerOpen(false)}
onAdded={() => {
commitHaptic();
exitSelection();
}}
/>
) : null}
{sortSheetOpen ? (
<AppSheet onClose={() => setSortSheetOpen(false)}>
<AppSheetSection label={sortSheetLabel} />
{sortItems.map(({ key, label, selected, onSelect }) => (
<AppSheetItem
key={key}
label={label}
selected={selected}
onPress={() => {
onSelect();
setSortSheetOpen(false);
}}
/>
))}
</AppSheet>
) : null}
</Screen>
);
}
@@ -243,8 +466,19 @@ const styles = StyleSheet.create({
paddingVertical: spacing.xs,
marginBottom: spacing.xs,
},
// Same vertical rhythm as sortTrigger so entering selection doesn't shift the list.
selectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs,
marginBottom: spacing.xs,
},
gridCell: {
flex: 1,
paddingHorizontal: spacing.xs,
},
listArea: {
flex: 1,
},
});
+88 -153
View File
@@ -1,20 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import {
useEffect,
useMemo,
useState
} from 'react';
import {
View,
Pressable,
StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { colors, radius, spacing } from '@/theme';
import { TrackActionsSheet, type TrackActionSheetItem } from '@/components/library/TrackActionsSheet';
import {
AppSheet,
AppSheetItem,
AppSheetTitle
} from '@/components/sheets/AppSheet';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { colors, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { artworkThumbUri, artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
import type { DbTrack } from '@/types/library';
@@ -56,6 +70,9 @@ export default function PlaylistScreen() {
const removeFromPlaylist = usePlaylistStore((s) => s.removeFromPlaylist);
const markPlayed = usePlaylistStore((s) => s.markPlayed);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
const [actionEntry, setActionEntry] = useState<PlaylistTrackEntry | null>(null);
const [missingEntry, setMissingEntry] = useState<PlaylistTrackEntry | null>(null);
@@ -120,7 +137,7 @@ export default function PlaylistScreen() {
// Move/remove only exist on real playlists; favorites rows use the standard
// sheet (its favorite toggle is the "remove" affordance there).
const extraItems: ActionSheetItem[] =
const extraItems: TrackActionSheetItem[] =
playlistId != null && actionEntry
? [
{
@@ -154,76 +171,27 @@ export default function PlaylistScreen() {
]
: [];
const meta = [
`${playable.length} ${playable.length === 1 ? 'track' : 'tracks'}`,
entries.length > playable.length ? `${entries.length - playable.length} missing` : null,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ');
return (
<Screen>
<Pressable style={styles.back} onPress={handleBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Library
</Text>
</Pressable>
<View style={styles.header}>
<View style={styles.art}>
{coverHash ? (
<Image
source={{ uri: artworkUri(coverHash) }}
style={styles.artImage}
contentFit="cover"
transition={120}
/>
) : (
<Ionicons
name={isFavorites ? 'heart' : 'musical-notes-outline'}
size={36}
color={isFavorites ? colors.accent : colors.textTertiary}
/>
)}
</View>
<View style={styles.headerMeta}>
<Text variant="heading" numberOfLines={2}>
{name}
</Text>
<Text variant="label">
{[
`${playable.length} ${playable.length === 1 ? 'track' : 'tracks'}`,
entries.length > playable.length ? `${entries.length - playable.length} missing` : null,
formatDuration(totalDuration),
]
.filter(Boolean)
.join(' · ')}
</Text>
<View style={styles.buttons}>
<Pressable
style={[styles.playButton, playable.length === 0 && styles.buttonDisabled]}
disabled={playable.length === 0}
onPress={() => startPlayback(0)}
accessibilityRole="button"
>
<Ionicons name="play" size={16} color={colors.bgPrimary} />
<Text variant="body" style={styles.playLabel}>
Play
</Text>
</Pressable>
<Pressable
style={[styles.shuffleButton, playable.length === 0 && styles.buttonDisabled]}
disabled={playable.length === 0}
onPress={startShuffle}
accessibilityRole="button"
>
<Ionicons name="shuffle" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
Shuffle
</Text>
</Pressable>
</View>
</View>
</View>
<Screen padded={false} style={styles.screen}>
<FlashList
data={entries}
keyExtractor={(entry) => String(entry.id)}
showsVerticalScrollIndicator={false}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: insets.top + expandedHeight,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxl,
}}
renderItem={({ item }) =>
item.track ? (
<TrackRow
@@ -238,102 +206,69 @@ export default function PlaylistScreen() {
)
}
/>
<CollapsingHeader
artwork={
coverHash ? (
<Image source={{ uri: artworkUri(coverHash) }} style={styles.artFill} contentFit="cover" transition={150} />
) : (
<Ionicons
name={isFavorites ? 'heart' : 'musical-notes-outline'}
size={56}
color={isFavorites ? colors.accent : colors.textTertiary}
/>
)
}
backdropUri={coverHash ? artworkThumbUri(coverHash) : null}
title={name}
heroMeta={<Text variant="label">{meta}</Text>}
disabled={playable.length === 0}
onBack={handleBack}
onPlay={() => startPlayback(0)}
onShuffle={startShuffle}
scrollY={scrollY}
heroFaded={heroFaded}
collapsed={collapsed}
expandedHeight={expandedHeight}
onHeroBlockLayout={onHeroBlockLayout}
/>
<TrackActionsSheet
track={actionEntry?.track ?? null}
onClose={() => setActionEntry(null)}
extraItems={extraItems}
/>
<ActionSheet
visible={missingEntry !== null}
title={missingEntry?.fallback_title ?? 'Missing track'}
items={
playlistId != null && missingEntry
? [
{
key: 'remove',
label: 'Remove from playlist',
icon: 'remove-circle-outline',
destructive: true,
onPress: () => {
void removeFromPlaylist(playlistId, missingEntry.track_path);
setMissingEntry(null);
},
},
]
: []
}
onClose={() => setMissingEntry(null)}
/>
{missingEntry !== null ? (
<AppSheet onClose={() => setMissingEntry(null)}>
<AppSheetTitle
title={missingEntry.fallback_title ?? 'Missing track'}
subtitle={missingEntry.fallback_artist ?? 'Track not in library'}
/>
{playlistId != null ? (
<AppSheetItem
label="Remove from playlist"
icon="remove-circle-outline"
destructive
onPress={() => {
void removeFromPlaylist(playlistId, missingEntry.track_path);
setMissingEntry(null);
}}
/>
) : null}
</AppSheet>
) : null}
</Screen>
);
}
const styles = StyleSheet.create({
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: spacing.md,
marginBottom: spacing.md,
alignSelf: 'flex-start',
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
},
header: {
flexDirection: 'row',
gap: spacing.lg,
marginBottom: spacing.lg,
},
art: {
width: 128,
height: 128,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
artFill: {
width: '100%',
height: '100%',
},
headerMeta: {
flex: 1,
justifyContent: 'center',
gap: spacing.xs,
},
buttons: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.xs,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accent,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
shuffleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
buttonDisabled: {
opacity: 0.4,
},
playLabel: {
color: colors.bgPrimary,
fontWeight: '600',
},
missingRow: {
flexDirection: 'row',
alignItems: 'center',
+11 -3
View File
@@ -1,4 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import {
useEffect,
useMemo,
useState
} from 'react';
import {
ActivityIndicator,
Alert,
@@ -9,7 +13,7 @@ import {
StyleSheet,
TextInput,
useWindowDimensions,
View,
View
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
@@ -20,7 +24,11 @@ import { MarqueeText } from '@/components/MarqueeText';
import { Screen } from '@/components/Screen';
import { SeekBar } from '@/components/SeekBar';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
+16 -3
View File
@@ -1,11 +1,24 @@
import { useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import {
ActivityIndicator,
Pressable,
StyleSheet,
View
} from 'react-native';
import {
CameraView,
useCameraPermissions,
type BarcodeScanningResult
} from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
export default function DesktopRemoteScanScreen() {
+6 -2
View File
@@ -8,13 +8,17 @@ import {
ScrollView,
StyleSheet,
TextInput,
View,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import type { LastFmScrobbleProtocol } from '@/types/lastFm';
+13 -2
View File
@@ -1,10 +1,21 @@
import { useEffect } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Switch, View } from 'react-native';
import {
Alert,
Pressable,
ScrollView,
StyleSheet,
Switch,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { requestLastFmFlush } from '@/services/lastfm';
import type { LastFmProfileStatus } from '@/types/lastFm';
+13 -4
View File
@@ -1,5 +1,10 @@
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
import {
View,
Pressable,
StyleSheet,
useWindowDimensions
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
@@ -11,7 +16,7 @@ import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
withTiming
} from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
@@ -22,7 +27,11 @@ import { WaveformSeekBar } from '@/components/WaveformSeekBar';
import { Visualizer } from '@/components/Visualizer';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { QueueTray } from '@/components/queue/QueueTray';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
import { motion } from '@/theme/motion';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
@@ -37,7 +46,7 @@ import {
skipToNext,
skipToPrevious,
togglePlay,
toggleShuffle,
toggleShuffle
} from '@/audio/playbackController';
const DISMISS_DISTANCE = 140;
+5 -1
View File
@@ -1,5 +1,9 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
+6 -2
View File
@@ -7,13 +7,17 @@ import {
ScrollView,
StyleSheet,
TextInput,
View,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceType } from '@/types/remote';
+12 -2
View File
@@ -1,11 +1,21 @@
import { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import {
Alert,
Pressable,
ScrollView,
StyleSheet,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { colors, radius, spacing } from '@/theme';
import {
colors,
radius,
spacing
} from '@/theme';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceRow, RemoteSyncProgress } from '@/types/remote';