artist image search

This commit is contained in:
Boof2015
2026-07-31 01:26:36 -04:00
parent fe7c5a70a4
commit a42e78017c
44 changed files with 4705 additions and 112 deletions
@@ -0,0 +1,265 @@
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Linking,
Pressable,
StyleSheet,
View,
} from 'react-native';
import { Image } from 'expo-image';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Ionicons } from '@expo/vector-icons';
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { searchArtistImageCandidates } from '@/library/artistImageLookup';
import type { DeezerArtistCandidate } from '@/types/artistImages';
export function ArtistImageSearchSheet({
artistName,
onClose,
onSelect,
}: {
artistName: string;
onClose: () => void;
onSelect: (candidate: DeezerArtistCandidate) => Promise<void>;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const [query, setQuery] = useState(artistName);
const [candidates, setCandidates] = useState<DeezerArtistCandidate[]>([]);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const search = async (value = query) => {
const trimmed = value.trim();
if (!trimmed || loading) return;
setLoading(true);
setError(null);
try {
const result = await searchArtistImageCandidates(trimmed);
if (result.status === 'transient_error') {
setCandidates([]);
setError(result.message);
} else {
setCandidates(result.candidates);
if (result.candidates.length === 0) {
setError('No artist images matched that search.');
}
}
} finally {
setLoading(false);
}
};
useEffect(() => {
queueMicrotask(() => void search(artistName));
// Run once with the artist name used to open this sheet.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artistName]);
const choose = async (candidate: DeezerArtistCandidate) => {
if (selectedId) return;
setSelectedId(candidate.id);
setError(null);
try {
await onSelect(candidate);
onClose();
} catch {
setError('That image could not be downloaded. Check your connection and try again.');
setSelectedId(null);
}
};
const openDeezerLink = async (url: string) => {
try {
await Linking.openURL(url);
} catch {
setError('Astra could not open the Deezer link.');
}
};
return (
<AppSheet onClose={onClose} scrollable>
<AppSheetTitle
title="Search Deezer"
subtitle="Choose the artist—not an album cover"
/>
<View style={styles.searchRow}>
<BottomSheetTextInput
value={query}
onChangeText={setQuery}
onSubmitEditing={() => void search()}
placeholder="Artist name"
placeholderTextColor={colors.textTertiary}
returnKeyType="search"
autoCapitalize="words"
style={styles.input}
accessibilityLabel="Deezer artist search"
/>
<Pressable
android_ripple={ripple.bounded}
style={styles.searchButton}
onPress={() => void search()}
accessibilityRole="button"
accessibilityLabel="Search Deezer"
>
{loading ? (
<ActivityIndicator size="small" color={colors.accentTextStrong} />
) : (
<Ionicons name="search" size={20} color={colors.accentTextStrong} />
)}
</Pressable>
</View>
{error ? (
<View style={styles.message}>
<Ionicons name="information-circle-outline" size={18} color={colors.textSecondary} />
<Text variant="caption" color={colors.textSecondary} style={styles.messageText}>
{error}
</Text>
</View>
) : null}
<View style={styles.results}>
{candidates.map((candidate) => (
<Pressable
key={candidate.id}
android_ripple={ripple.bounded}
style={styles.candidate}
onPress={() => void choose(candidate)}
accessibilityRole="button"
accessibilityLabel={`Use Deezer image for ${candidate.name}`}
>
<Image
source={{ uri: candidate.imageUrl }}
style={styles.thumbnail}
contentFit="cover"
transition={100}
/>
<View style={styles.candidateText}>
<Text variant="body" numberOfLines={1}>{candidate.name}</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{formatFans(candidate.fanCount)}
</Text>
</View>
{candidate.linkUrl ? (
<Pressable
style={styles.providerLink}
hitSlop={8}
onPress={(event) => {
event.stopPropagation();
void openDeezerLink(candidate.linkUrl!);
}}
accessibilityRole="link"
accessibilityLabel={`Open ${candidate.name} on Deezer`}
>
<Ionicons name="open-outline" size={17} color={colors.textSecondary} />
</Pressable>
) : null}
{selectedId === candidate.id ? (
<ActivityIndicator size="small" color={colors.accent} />
) : (
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
)}
</Pressable>
))}
</View>
<Pressable
style={styles.attribution}
onPress={() => void openDeezerLink('https://www.deezer.com/')}
accessibilityRole="link"
>
<Text variant="caption" color={colors.textSecondary}>Images and artist data from Deezer</Text>
<Ionicons name="open-outline" size={14} color={colors.textSecondary} />
</Pressable>
</AppSheet>
);
}
function formatFans(count: number): string {
return `${new Intl.NumberFormat().format(count)} Deezer fans`;
}
const useStyles = createThemedStyles((colors) => ({
searchRow: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'center',
},
input: {
flex: 1,
height: 48,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
color: colors.textPrimary,
paddingHorizontal: spacing.md,
fontFamily: 'Inter_400Regular',
fontSize: 16,
},
searchButton: {
width: 48,
height: 48,
borderRadius: radius.md,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accentGlow,
overflow: 'hidden',
},
message: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
},
messageText: {
flex: 1,
},
results: {
marginTop: spacing.sm,
},
candidate: {
minHeight: 76,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.glassBorder,
},
thumbnail: {
width: 58,
height: 58,
borderRadius: radius.pill,
backgroundColor: colors.bgTertiary,
},
candidateText: {
flex: 1,
minWidth: 0,
gap: 3,
},
providerLink: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.pill,
},
attribution: {
minHeight: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
},
}));
@@ -0,0 +1,131 @@
import { useEffect, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useArtistImageStore } from '@/stores/artistImageStore';
import { requeueMissingArtistImages } from '@/library/artistImageLookup';
const n = (value: number) => value.toLocaleString();
/**
* Live sweep progress, or — when idle — how many artists still have no portrait
* plus a way to look again. The retry exists because `not_found` is terminal in
* the pending query: without it, re-checking those artists would mean rescanning
* the whole library.
*/
export function ArtistImageSweepStatus({ enabled }: { enabled: boolean }) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const running = useArtistImageStore((s) => s.running);
const processed = useArtistImageStore((s) => s.processed);
const total = useArtistImageStore((s) => s.total);
const missing = useArtistImageStore((s) => s.missing);
const refreshMissing = useArtistImageStore((s) => s.refreshMissing);
const [retrying, setRetrying] = useState(false);
useEffect(() => {
void refreshMissing();
}, [refreshMissing]);
if (!enabled) return null;
if (running) {
// Clamped: the denominator is counted once up front and normalizes names
// slightly differently than the grouping does, so it can drift by a few.
const fraction = total > 0 ? Math.min(processed / total, 1) : 0;
return (
<View style={styles.container}>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{total > 0
? `Looking up artist images… ${n(processed)} of ${n(total)}`
: 'Looking up artist images…'}
</Text>
<View style={styles.track}>
<View
style={[
styles.fill,
fraction > 0 ? { width: `${fraction * 100}%` } : styles.fillIndeterminate,
]}
/>
</View>
</View>
);
}
if (missing <= 0) return null;
const retry = async () => {
if (retrying) return;
setRetrying(true);
try {
await requeueMissingArtistImages();
} finally {
setRetrying(false);
}
};
return (
<View style={styles.container}>
<Text variant="caption" color={colors.textSecondary}>
{missing === 1 ? '1 artist has no image' : `${n(missing)} artists have no image`}
</Text>
<Pressable
android_ripple={ripple.bounded}
style={styles.button}
disabled={retrying}
onPress={() => void retry()}
accessibilityRole="button"
accessibilityLabel="Look for missing artist images now"
>
{retrying ? (
<ActivityIndicator size="small" color={colors.accentTextStrong} />
) : (
<Ionicons name="refresh" size={16} color={colors.accentTextStrong} />
)}
<Text variant="label" color={colors.accentTextStrong}>
Look for missing images
</Text>
</Pressable>
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
container: {
gap: spacing.sm,
marginTop: spacing.md,
},
track: {
height: 2,
backgroundColor: colors.glassBorder,
borderRadius: 1,
overflow: 'hidden',
},
fill: {
height: 2,
backgroundColor: colors.accent,
},
fillIndeterminate: {
width: '100%',
opacity: 0.35,
},
button: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.accentGlow,
overflow: 'hidden',
},
}));
export default ArtistImageSweepStatus;
+3 -1
View File
@@ -128,6 +128,7 @@ export function CollapsingHeader({
onBack,
backLabel,
onMore,
moreAccessibilityLabel = 'More options',
onPlay,
onShuffle,
scrollY,
@@ -149,6 +150,7 @@ export function CollapsingHeader({
/** Names the screen `onBack` returns to; must track the real action. */
backLabel: string;
onMore?: () => void;
moreAccessibilityLabel?: string;
onPlay: () => void;
onShuffle: () => void;
scrollY: SharedValue<number>;
@@ -327,7 +329,7 @@ export function CollapsingHeader({
hitSlop={8}
style={[styles.moreButton, { top: barCenterY - 16, right: spacing.md }]}
accessibilityRole="button"
accessibilityLabel="Playlist options"
accessibilityLabel={moreAccessibilityLabel}
>
<Ionicons name="ellipsis-horizontal" size={22} color={colors.textPrimary} />
</Pressable>
@@ -0,0 +1,123 @@
import {
ActivityIndicator,
Pressable,
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useScanNotificationPermission } from '@/library/useScanNotificationPermission';
/**
* Dense settings-list form of the notification permission, rendered only while
* there is still something to ask for. A permanently-satisfied "Allowed" row is
* noise in a settings list, so the card removes itself once the permission is
* held (or was never required) — including the surrounding spacing, which is why
* the caller passes `style` instead of wrapping this in its own View.
*
* The onboarding wizard deliberately does not reuse this: it has a whole page to
* fill, so it renders its own layout over the same
* `useScanNotificationPermission` state.
*/
export function ScanNotificationPermissionCard({
style,
}: {
style?: StyleProp<ViewStyle>;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const { state, granted, denied, working, resolve } = useScanNotificationPermission();
// Also hidden while the first check is in flight, so the row never appears
// just to vanish a frame later on an already-granted device.
if (state === null || granted) return null;
return (
<View style={[styles.card, style]}>
<View style={styles.header}>
<View style={styles.icon}>
<Ionicons name="notifications-outline" size={20} color={colors.accent} />
</View>
<View style={styles.copy}>
<Text variant="body">Scan progress notification</Text>
<Text variant="caption" color={colors.textSecondary}>
The temporary notification shows progress and lets Android keep a scan running
after you leave Astra. Scans still work if you skip it.
</Text>
</View>
</View>
<Pressable
android_ripple={ripple.bounded}
style={styles.button}
disabled={working}
onPress={resolve}
accessibilityRole="button"
>
{working ? (
<ActivityIndicator size="small" color={colors.accentTextStrong} />
) : (
<Ionicons
name={denied ? 'settings-outline' : 'notifications-outline'}
size={18}
color={colors.accentTextStrong}
/>
)}
<Text variant="label" color={colors.accentTextStrong}>
{denied ? 'Open Settings' : 'Allow scan notifications'}
</Text>
</Pressable>
{denied ? (
<Text variant="caption" color={colors.textTertiary}>
Notification permission is denied. You can enable it in Android settings.
</Text>
) : null}
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
card: {
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
},
header: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
},
icon: {
width: 36,
height: 36,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accentGlow,
},
copy: {
flex: 1,
gap: 4,
},
button: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.accentGlow,
overflow: 'hidden',
},
}));
@@ -0,0 +1,128 @@
import { useState } from 'react';
import { ActivityIndicator, Modal, Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { SegmentedControl } from '@/components/SegmentedControl';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useSettingsStore } from '@/stores/settingsStore';
import type { ArtistImageAutoPolicy } from '@/types/artistImages';
export function ArtistImageDisclosurePrompt() {
const loaded = useSettingsStore((s) => s.loaded);
const seen = useSettingsStore((s) => s.artistImageDisclosureSeen);
const policy = useSettingsStore((s) => s.artistImageAutoPolicy);
if (!loaded || seen) return null;
return <ArtistImageDisclosureContent initialPolicy={policy} />;
}
function ArtistImageDisclosureContent({
initialPolicy,
}: {
initialPolicy: ArtistImageAutoPolicy;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const [policy, setPolicy] = useState<ArtistImageAutoPolicy>(initialPolicy);
const [saving, setSaving] = useState(false);
const continueSetup = async () => {
if (saving) return;
setSaving(true);
try {
await useSettingsStore.getState().setArtistImageAutoPolicy(policy);
await useSettingsStore.getState().acknowledgeArtistImageDisclosure();
} finally {
setSaving(false);
}
};
return (
<Modal
visible
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => undefined}
>
<View style={styles.backdrop}>
<View style={styles.card}>
<View style={styles.icon}>
<Ionicons name="person-circle-outline" size={28} color={colors.accent} />
</View>
<View style={styles.copy}>
<Text variant="heading">Set up artist images</Text>
<Text variant="body" color={colors.textSecondary}>
Astra can send artist names to Deezer, then store selected portraits locally
so they still appear offline. Existing album art remains the fallback.
</Text>
</View>
<SegmentedControl
segments={[
{ key: 'wifi', label: 'Wi-Fi' },
{ key: 'any', label: 'Any network' },
{ key: 'off', label: 'Off' },
]}
value={policy}
onChange={(value) => setPolicy(value as ArtistImageAutoPolicy)}
/>
<Text variant="caption" color={colors.textTertiary}>
Ethernet is included in Wi-Fi mode. You can change this later in Settings Library,
and manual searches work while automatic downloads are off.
</Text>
<Pressable
android_ripple={ripple.bounded}
style={styles.button}
onPress={() => void continueSetup()}
disabled={saving}
accessibilityRole="button"
>
{saving ? (
<ActivityIndicator size="small" color={colors.bgPrimary} />
) : (
<Text variant="label" color={colors.bgPrimary}>Continue</Text>
)}
</Pressable>
</View>
</View>
</Modal>
);
}
const useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
justifyContent: 'center',
padding: spacing.xl,
backgroundColor: colors.backdrop,
},
card: {
gap: spacing.lg,
padding: spacing.xl,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
},
icon: {
width: 52,
height: 52,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.pill,
backgroundColor: colors.accentGlow,
},
copy: {
gap: spacing.sm,
},
button: {
minHeight: 50,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
backgroundColor: colors.accent,
overflow: 'hidden',
},
}));
@@ -0,0 +1,322 @@
import { useEffect, type ComponentProps, type ReactNode } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { StepHeader } from '@/components/onboarding/StepHeader';
import { radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { playHaptic } from '@/lib/haptics';
import { useSettingsStore } from '@/stores/settingsStore';
import type { ArtistImageAutoPolicy } from '@/types/artistImages';
type IoniconName = ComponentProps<typeof Ionicons>['name'];
const POLICY_OPTIONS: {
policy: ArtistImageAutoPolicy;
icon: IoniconName;
title: string;
description: string;
}[] = [
{
policy: 'wifi',
icon: 'wifi-outline',
title: 'Wi-Fi or Ethernet',
description: 'Recommended — never uses mobile data.',
},
{
policy: 'any',
icon: 'cellular-outline',
title: 'Any network',
description: 'Includes mobile data.',
},
{
policy: 'off',
icon: 'remove-circle-outline',
title: 'Off',
description: 'Nothing is sent. Manual search still works.',
},
];
/**
* Wizard page for the Deezer artist-image disclosure. Unlike the settings row
* this flattens the toggle-plus-segmented-control into three equal-weight cards:
* the network choice is the same decision as opting in, so hiding it behind a
* switch made a consent screen read as two unrelated controls.
*
* The current policy is preselected (unlike the scope-style step, which stays
* unset to avoid biasing taste feedback) — on a consent page the user needs to
* see what will actually happen if they just tap Continue.
*/
export function ArtistImageStep() {
const styles = useStyles();
const colors = useColors();
const policy = useSettingsStore((s) => s.artistImageAutoPolicy);
const setPolicy = useSettingsStore((s) => s.setArtistImageAutoPolicy);
const choose = (next: ArtistImageAutoPolicy) => {
if (next === policy) return;
playHaptic('selection');
void setPolicy(next);
};
return (
<View style={styles.stepBody}>
<StepHeader
icon="person-circle-outline"
title="Artist portraits"
subtitle="Astra can look up artist photos on Deezer and store them on your device, so they show up offline too."
/>
<PortraitPreview enabled={policy !== 'off'} />
<View style={styles.options}>
{POLICY_OPTIONS.map((option) => (
<PolicyCard
key={option.policy}
icon={option.icon}
title={option.title}
description={option.description}
selected={option.policy === policy}
onPress={() => choose(option.policy)}
/>
))}
</View>
<Text variant="caption" color={colors.textTertiary} style={styles.footnote}>
Only the artist name is sent. Change this anytime in Settings Library.
</Text>
</View>
);
}
/**
* Side-by-side sketch of an artist tile with a portrait vs. the album-art
* fallback. The highlight follows the choice below — picking "Off" moves it to
* the fallback tile, so the cards and the preview always agree.
*/
function PortraitPreview({ enabled }: { enabled: boolean }) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.preview}>
<PreviewTile active={enabled} label="With portraits">
<Ionicons
name="person"
size={22}
color={enabled ? colors.accent : colors.textTertiary}
/>
</PreviewTile>
<PreviewTile active={!enabled} label="Album art only">
<View style={styles.previewAlbumSquare} />
</PreviewTile>
</View>
);
}
/**
* Two stacked circle layers cross-faded by opacity rather than an animated
* border/background colour — the repo's established way to move a highlight
* without handing colours to a worklet.
*/
function PreviewTile({
active,
label,
children,
}: {
active: boolean;
label: string;
children: ReactNode;
}) {
const styles = useStyles();
const colors = useColors();
const progress = useSharedValue(active ? 1 : 0);
useEffect(() => {
progress.value = withTiming(active ? 1 : 0, motion.snap);
}, [active, progress]);
const tileStyle = useAnimatedStyle(() => ({ opacity: 0.42 + progress.value * 0.58 }));
const accentStyle = useAnimatedStyle(() => ({ opacity: progress.value }));
return (
<Animated.View style={[styles.previewItem, tileStyle]}>
<View style={styles.previewCircleWrap}>
<View style={[styles.previewCircleLayer, styles.previewCircleNeutral]} />
<Animated.View
style={[styles.previewCircleLayer, styles.previewCircleAccent, accentStyle]}
/>
{children}
</View>
<View style={styles.previewNameLine} />
<Text variant="caption" color={active ? colors.textSecondary : colors.textTertiary}>
{label}
</Text>
</Animated.View>
);
}
function PolicyCard({
icon,
title,
description,
selected,
onPress,
}: {
icon: IoniconName;
title: string;
description: string;
selected: boolean;
onPress: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
return (
<Pressable
android_ripple={ripple.bounded}
style={[styles.card, selected && styles.cardSelected]}
onPress={onPress}
accessibilityRole="radio"
accessibilityState={{ selected }}
accessibilityLabel={`${title}. ${description}`}
>
<View style={[styles.cardIcon, selected && styles.cardIconSelected]}>
<Ionicons
name={icon}
size={20}
color={selected ? colors.accentTextStrong : colors.textSecondary}
/>
</View>
<View style={styles.cardCopy}>
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{title}
</Text>
<Text variant="caption" color={colors.textSecondary}>
{description}
</Text>
</View>
{/* Always occupies its slot. Rendering the checkmark only when selected
narrowed the copy column on tap, which re-wrapped the longer
descriptions and changed the height of the whole page. */}
<View style={styles.cardCheck}>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<View style={styles.cardCheckEmpty} />
)}
</View>
</Pressable>
);
}
const useStyles = createThemedStyles((colors) => ({
stepBody: {
width: '100%',
gap: spacing.lg,
},
preview: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xl,
},
previewItem: {
alignItems: 'center',
gap: spacing.sm,
},
previewCircleWrap: {
width: 56,
height: 56,
alignItems: 'center',
justifyContent: 'center',
},
previewCircleLayer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 28,
},
previewCircleNeutral: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
},
previewCircleAccent: {
borderWidth: 1,
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
previewAlbumSquare: {
width: 24,
height: 24,
borderRadius: 4,
borderWidth: 1.5,
borderColor: colors.textTertiary,
},
previewNameLine: {
width: 34,
height: 5,
borderRadius: 2.5,
backgroundColor: colors.glassBorder,
},
options: {
gap: spacing.sm,
},
card: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
minHeight: 68,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
cardSelected: {
borderWidth: 1,
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
cardIcon: {
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
},
cardIconSelected: {
backgroundColor: colors.bgSecondary,
},
cardCopy: {
flex: 1,
minWidth: 0,
gap: 2,
},
cardCheck: {
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
},
cardCheckEmpty: {
width: 16,
height: 16,
borderRadius: 8,
borderWidth: 1.5,
borderColor: colors.glassBorder,
},
footnote: {
textAlign: 'center',
},
}));
export default ArtistImageStep;
@@ -0,0 +1,195 @@
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { StepHeader } from '@/components/onboarding/StepHeader';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useLibraryStore } from '@/stores/libraryStore';
import type { ScanNotificationPermission } from '@/library/useScanNotificationPermission';
/**
* Wizard page for the POST_NOTIFICATIONS grant. Deliberately not the settings
* `ScanNotificationPermissionCard`: with a whole page to work with, the ask gets
* a sketch of the actual notification and one unmistakable action instead of a
* dense list row. The permission state is owned by OnboardingFlow so the footer
* button can say "Continue" vs "Skip for now" from the same source of truth.
*/
export function NotificationStep({
permission,
}: {
permission: ScanNotificationPermission;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const isScanning = useLibraryStore((s) => s.isScanning);
const { state, granted, denied, working, resolve } = permission;
return (
<View style={styles.stepBody}>
<StepHeader
icon="notifications-outline"
title="Keep scans running"
subtitle={
isScanning
? 'Your scan is running now. Android needs permission to show its progress and keep it going after you leave Astra.'
: 'Astra shows a temporary progress notification while scanning. It is what lets Android keep a scan running after you leave the app.'
}
/>
<NotificationSketch />
{state === null ? (
<ActivityIndicator size="small" color={colors.accent} />
) : granted ? (
<View style={styles.grantedRow}>
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
<Text variant="body" color={colors.textPrimary}>
{state === 'not_required'
? 'No permission needed on this Android version'
: 'Notifications allowed'}
</Text>
</View>
) : (
<Pressable
android_ripple={ripple.bounded}
style={styles.button}
disabled={working}
onPress={resolve}
accessibilityRole="button"
accessibilityLabel={denied ? 'Open Android settings' : 'Allow scan notifications'}
>
{working ? (
<ActivityIndicator size="small" color={colors.accentTextStrong} />
) : (
<Ionicons
name={denied ? 'settings-outline' : 'notifications-outline'}
size={19}
color={colors.accentTextStrong}
/>
)}
<Text variant="label" color={colors.accentTextStrong}>
{denied ? 'Open Android settings' : 'Allow notifications'}
</Text>
</Pressable>
)}
<Text variant="caption" color={colors.textTertiary} style={styles.footnote}>
{denied
? 'Notifications are currently blocked for Astra. Scans still work — they just stop early if Android needs the memory.'
: 'Optional. Scans still work without it, but Android may stop a long scan once you leave the app.'}
</Text>
</View>
);
}
/**
* Miniature of the real scan notification. Static numbers on purpose — a live
* counter here would compete with the actual ScanBanner above the page.
*/
function NotificationSketch() {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.sketch}>
<View style={styles.sketchHeader}>
<View style={styles.sketchAppIcon}>
<Ionicons name="musical-note" size={11} color={colors.bgPrimary} />
</View>
<Text variant="caption" color={colors.textSecondary}>
Astra
</Text>
<View style={styles.sketchSeparator} />
<Text variant="caption" color={colors.textTertiary}>
now
</Text>
</View>
<Text variant="body" numberOfLines={1}>
Scanning your library
</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
1,204 of 3,180 tracks
</Text>
<View style={styles.sketchTrack}>
<View style={styles.sketchFill} />
</View>
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
stepBody: {
width: '100%',
gap: spacing.lg,
},
sketch: {
gap: spacing.xs,
padding: spacing.md,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
},
sketchHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: 2,
},
sketchAppIcon: {
width: 18,
height: 18,
borderRadius: 5,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accent,
},
sketchSeparator: {
width: 3,
height: 3,
borderRadius: 1.5,
backgroundColor: colors.textTertiary,
},
sketchTrack: {
height: 4,
borderRadius: 2,
overflow: 'hidden',
backgroundColor: colors.bgTertiary,
marginTop: spacing.xs,
},
sketchFill: {
width: '38%',
height: '100%',
borderRadius: 2,
backgroundColor: colors.accent,
},
button: {
minHeight: 52,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
borderRadius: radius.md,
backgroundColor: colors.accentGlow,
overflow: 'hidden',
},
grantedRow: {
minHeight: 52,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
footnote: {
textAlign: 'center',
},
}));
export default NotificationStep;
+43 -50
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type ComponentProps } from 'react';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Pressable,
@@ -23,6 +23,9 @@ import { Text } from '@/components/Text';
import { ScanProgress } from '@/components/library/ScanProgress';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
import { StepHeader } from '@/components/onboarding/StepHeader';
import { NotificationStep } from '@/components/onboarding/NotificationStep';
import { ArtistImageStep } from '@/components/onboarding/ArtistImageStep';
import { formatFolderCount, formatTrackCount } from '@/components/settings/SettingsPanels';
import { radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
@@ -30,14 +33,33 @@ import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { playHaptic } from '@/lib/haptics';
import type { BaseThemeId } from '@/theme/resolve';
import { useScanNotificationPermission } from '@/library/useScanNotificationPermission';
import { useLibraryStore } from '@/stores/libraryStore';
import { useSettingsStore, type NowPlayingScopeStyle } from '@/stores/settingsStore';
import { useThemeStore } from '@/stores/themeStore';
type IoniconName = ComponentProps<typeof Ionicons>['name'];
type StepId = 'welcome' | 'library' | 'theme' | 'player' | 'done';
type StepId =
| 'welcome'
| 'library'
| 'notifications'
| 'artistImages'
| 'theme'
| 'player'
| 'done';
const STEP_ORDER: StepId[] = ['welcome', 'library', 'theme', 'player', 'done'];
// Folders first so the notification ask lands with a visible reason ("your scan
// is running"), then the Deezer consent. Each is its own page: they are three
// unrelated decisions and stacking them made the library step's real action —
// picking a folder — the third thing on screen.
const STEP_ORDER: StepId[] = [
'welcome',
'library',
'notifications',
'artistImages',
'theme',
'player',
'done',
];
const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [
{ id: 'system', title: 'System' },
@@ -63,6 +85,9 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
const step = STEP_ORDER[stepIndex];
const foldersCount = useLibraryStore((s) => s.folders.length);
const isScanning = useLibraryStore((s) => s.isScanning);
// Owned here so the footer label can distinguish "Continue" from "Skip for
// now" using the same state the notification step renders.
const notificationPermission = useScanNotificationPermission();
// Deliberately unset until tapped: preselecting a card would bias the
// pre-release style feedback. Skipping through keeps the store default.
const [scopeStyleChoice, setScopeStyleChoice] = useState<NowPlayingScopeStyle | null>(null);
@@ -77,7 +102,7 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
};
const goBack = () => setStepIndex((i) => Math.max(0, i - 1));
const canGoBack = step === 'library' || step === 'theme' || step === 'player';
const canGoBack = stepIndex > 0 && step !== 'done';
const primaryLabel =
step === 'welcome'
? 'Get started'
@@ -88,9 +113,14 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
foldersCount > 0 || isScanning
? 'Continue'
: 'Skip for now'
: step === 'theme' || step === 'player'
? 'Continue'
: 'Start listening';
: step === 'notifications'
? // The grant is optional, so moving on without it really is skipping.
notificationPermission.granted
? 'Continue'
: 'Skip for now'
: step === 'artistImages' || step === 'theme' || step === 'player'
? 'Continue'
: 'Start listening';
return (
<View style={styles.root}>
@@ -123,6 +153,10 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
<Animated.View key={step} entering={FadeIn.duration(220)} style={styles.stepWrap}>
{step === 'welcome' ? <WelcomeStep /> : null}
{step === 'library' ? <LibraryStep /> : null}
{step === 'notifications' ? (
<NotificationStep permission={notificationPermission} />
) : null}
{step === 'artistImages' ? <ArtistImageStep /> : null}
{step === 'theme' ? <ThemeStep /> : null}
{step === 'player' ? (
<PlayerStep choice={scopeStyleChoice} onChoose={chooseScopeStyle} />
@@ -203,6 +237,7 @@ function LibraryStep() {
title="Add your music"
subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified."
/>
<Pressable android_ripple={ripple.bounded}
style={[styles.choiceButton, isScanning && styles.disabled]}
disabled={isScanning}
@@ -336,32 +371,6 @@ function DoneStep() {
);
}
function StepHeader({
icon,
title,
subtitle,
}: {
icon: IoniconName;
title: string;
subtitle: string;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.stepHeader}>
<View style={styles.stepIconWrap}>
<Ionicons name={icon} size={26} color={colors.accent} />
</View>
<Text variant="heading" style={styles.centeredTitle}>
{title}
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
{subtitle}
</Text>
</View>
);
}
/** Subtle "still scanning" pill shown at the top of steps after the library step. */
function ScanBanner() {
const styles = useStyles();
@@ -496,22 +505,6 @@ const useStyles = createThemedStyles((colors) => ({
width: '100%',
gap: spacing.lg,
},
stepHeader: {
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.xs,
},
stepIconWrap: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.glassBg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.xs,
},
choiceButton: {
flexDirection: 'row',
alignItems: 'center',
+67
View File
@@ -0,0 +1,67 @@
import type { ComponentProps } from 'react';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
type IoniconName = ComponentProps<typeof Ionicons>['name'];
/**
* Shared masthead for every wizard page: medallion icon, title, and the one-line
* reason the page exists. Lives outside OnboardingFlow so the individual step
* files can use it without importing their own parent.
*/
export function StepHeader({
icon,
title,
subtitle,
}: {
icon: IoniconName;
title: string;
subtitle: string;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.stepHeader}>
<View style={styles.stepIconWrap}>
<Ionicons name={icon} size={26} color={colors.accent} />
</View>
<Text variant="heading" style={styles.title}>
{title}
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.subtitle}>
{subtitle}
</Text>
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
stepHeader: {
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.xs,
},
stepIconWrap: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.glassBg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.xs,
},
title: {
textAlign: 'center',
},
subtitle: {
textAlign: 'center',
maxWidth: 340,
},
}));
export default StepHeader;
@@ -6,6 +6,8 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { EQSlider } from '@/components/eq/EQSlider';
import { ScanProgress } from '@/components/library/ScanProgress';
import { ScanNotificationPermissionCard } from '@/components/library/ScanNotificationPermissionCard';
import { ArtistImageSweepStatus } from '@/components/library/ArtistImageSweepStatus';
import { SegmentedControl } from '@/components/SegmentedControl';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
@@ -393,11 +395,51 @@ export function LibrarySettingsPanel() {
const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists);
const setIncludeCollabArtists = useLibraryStore((s) => s.setIncludeCollabArtists);
const artistImageAutoPolicy = useSettingsStore((s) => s.artistImageAutoPolicy);
const setArtistImageAutoPolicy = useSettingsStore((s) => s.setArtistImageAutoPolicy);
const artistImagesEnabled = artistImageAutoPolicy !== 'off';
return (
<>
<SettingsSectionLabel>LOCAL FOLDERS</SettingsSectionLabel>
<LibraryFoldersSettings />
{/* Renders nothing once the permission is granted — the style carries the
spacing so no empty gap is left behind. */}
<ScanNotificationPermissionCard style={styles.cardSpacing} />
<SettingsSectionLabel spaced>ARTIST IMAGES</SettingsSectionLabel>
<SettingsCard>
<SettingsToggleRow
title="Automatic artist images"
description="Send artist names to Deezer and cache selected images locally for offline use."
value={artistImagesEnabled}
onValueChange={(enabled) =>
void setArtistImageAutoPolicy(enabled ? 'wifi' : 'off')
}
/>
{artistImagesEnabled ? (
<View style={styles.indent}>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
Download network
</Text>
<SegmentedControl
segments={[
{ key: 'wifi', label: 'Wi-Fi / Ethernet' },
{ key: 'any', label: 'Any network' },
]}
value={artistImageAutoPolicy}
onChange={(value) =>
void setArtistImageAutoPolicy(value === 'any' ? 'any' : 'wifi')
}
/>
</View>
) : (
<Text variant="caption" color={colors.textTertiary} style={styles.settingNote}>
Manual Deezer searches remain available from an artists menu.
</Text>
)}
<ArtistImageSweepStatus enabled={artistImagesEnabled} />
</SettingsCard>
<SettingsSectionLabel spaced>LIBRARY VIEW</SettingsSectionLabel>
<Text variant="body" style={styles.settingTitle}>