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