port desktop's search engine

This commit is contained in:
Boof2015
2026-06-28 09:46:08 -04:00
parent 3ec3f5ecf2
commit dcdddb350d
19 changed files with 2944 additions and 223 deletions
+3 -1
View File
@@ -18,6 +18,7 @@ import { ActionSheet } from '@/components/sheets/ActionSheet';
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 { dbTrackToTrack } from '@/library/trackAdapter';
import { sortTracks, TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
@@ -38,6 +39,7 @@ export default function LibraryScreen() {
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const openQuickSearch = useSearchStore((s) => s.openQuickSearch);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
@@ -60,7 +62,7 @@ export default function LibraryScreen() {
{!isEmpty ? (
<Pressable
hitSlop={8}
onPress={() => router.push('/library/search')}
onPress={() => openQuickSearch()}
accessibilityRole="button"
accessibilityLabel="Search library"
>
-220
View File
@@ -1,220 +0,0 @@
import { useDeferredValue, useMemo, useState } from 'react';
import { View, Pressable, StyleSheet, TextInput } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { AlbumRow } from '@/components/library/AlbumRow';
import { ArtistRow } from '@/components/library/ArtistRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import type { Album, Artist, DbTrack } from '@/types/library';
const TRACK_CAP = 50;
const ALBUM_CAP = 12;
const ARTIST_CAP = 12;
type SearchItem =
| { type: 'header'; key: string; label: string }
| { type: 'album'; key: string; album: Album }
| { type: 'artist'; key: string; artist: Artist }
| { type: 'track'; key: string; track: DbTrack; index: number };
function filterCap<T>(items: T[], predicate: (item: T) => boolean, cap: number): T[] {
const out: T[] = [];
for (const item of items) {
if (predicate(item)) {
out.push(item);
if (out.length >= cap) break;
}
}
return out;
}
export default function SearchScreen() {
const router = useRouter();
const tracks = useLibraryStore((s) => s.tracks);
const albums = useLibraryStore((s) => s.albums);
const artists = useLibraryStore((s) => s.artists);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [query, setQuery] = useState('');
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const needle = useDeferredValue(query.trim().toLocaleLowerCase());
const { items, trackResults } = useMemo(() => {
if (!needle) return { items: [] as SearchItem[], trackResults: [] as DbTrack[] };
// In-memory ≈ desktop searchTracks (LIKE %q% over title/artist/album).
const albumResults = filterCap(
albums,
(album) =>
album.album.toLocaleLowerCase().includes(needle) ||
album.artist.toLocaleLowerCase().includes(needle),
ALBUM_CAP
);
const artistResults = filterCap(
artists,
(artist) => artist.artist.toLocaleLowerCase().includes(needle),
ARTIST_CAP
);
const trackResults = filterCap(
tracks,
(track) =>
track.title.toLocaleLowerCase().includes(needle) ||
track.artist.toLocaleLowerCase().includes(needle) ||
track.album.toLocaleLowerCase().includes(needle),
TRACK_CAP
);
const items: SearchItem[] = [];
if (albumResults.length > 0) {
items.push({ type: 'header', key: 'header-albums', label: 'Albums' });
for (const album of albumResults) {
items.push({ type: 'album', key: `album-${album.identity_key}`, album });
}
}
if (artistResults.length > 0) {
items.push({ type: 'header', key: 'header-artists', label: 'Artists' });
for (const artist of artistResults) {
items.push({ type: 'artist', key: `artist-${artist.artist}`, artist });
}
}
if (trackResults.length > 0) {
items.push({ type: 'header', key: 'header-tracks', label: 'Tracks' });
trackResults.forEach((track, index) => {
items.push({ type: 'track', key: `track-${track.id}`, track, index });
});
}
return { items, trackResults };
}, [needle, tracks, albums, artists]);
const playFrom = (index: number) => {
void playTracks(trackResults.map(dbTrackToTrack), index);
};
return (
<Screen>
<View style={styles.searchBar}>
<Pressable onPress={() => router.back()} hitSlop={8} accessibilityRole="button">
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
</Pressable>
<TextInput
style={styles.input}
value={query}
onChangeText={setQuery}
placeholder="Search tracks, albums, artists"
placeholderTextColor={colors.textTertiary}
autoFocus
returnKeyType="search"
selectionColor={colors.accent}
/>
{query.length > 0 ? (
<Pressable onPress={() => setQuery('')} hitSlop={8} accessibilityRole="button">
<Ionicons name="close-circle" size={18} color={colors.textTertiary} />
</Pressable>
) : null}
</View>
{!needle ? (
<View style={styles.empty}>
<Text variant="caption">Search your library</Text>
</View>
) : items.length === 0 ? (
<View style={styles.empty}>
<Text variant="caption">No results for {query.trim()}</Text>
</View>
) : (
<FlashList
data={items}
keyExtractor={(item) => item.key}
getItemType={(item) => item.type}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
switch (item.type) {
case 'header':
return (
<Text variant="label" style={styles.sectionHeader}>
{item.label.toUpperCase()}
</Text>
);
case 'album':
return (
<AlbumRow
album={item.album}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.album.identity_key },
})
}
/>
);
case 'artist':
return (
<ArtistRow
artist={item.artist}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist.artist },
})
}
/>
);
case 'track':
return (
<TrackRow
track={item.track}
active={item.track.path === currentPath}
onPress={() => playFrom(item.index)}
onLongPress={() => setActionTrack(item.track)}
/>
);
}
}}
/>
)}
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
const styles = StyleSheet.create({
searchBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.md,
marginBottom: spacing.md,
},
input: {
flex: 1,
fontFamily: fonts.sans.regular,
fontSize: fontSize.base,
color: colors.textPrimary,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
empty: {
alignItems: 'center',
marginTop: spacing.xxl,
},
sectionHeader: {
marginTop: spacing.lg,
marginBottom: spacing.xs,
letterSpacing: 1,
},
});
+32
View File
@@ -8,8 +8,17 @@ import { colors, radius, spacing } from '@/theme';
import { useSettingsStore } from '@/stores/settingsStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
import type { LastFmStatus } from '@/types/lastFm';
function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
const connected = status?.profiles.filter((p) => p.connected).length ?? 0;
if (connected === 0) return 'Scrobble plays to Last.fm, ListenBrainz, and more.';
const base = `${connected} destination${connected === 1 ? '' : 's'} connected`;
return status?.enabled ? `${base}.` : `${base} · paused.`;
}
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
{
@@ -62,6 +71,7 @@ function ToggleRow({
export default function SettingsScreen() {
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
@@ -191,6 +201,28 @@ export default function SettingsScreen() {
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<Text
variant="label"
color={colors.textTertiary}
style={[styles.sectionLabel, styles.sectionSpacing]}
>
SCROBBLING
</Text>
<Pressable
style={styles.option}
onPress={() => router.push('/lastfm')}
accessibilityRole="button"
>
<Ionicons name="radio-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Last.fm &amp; scrobbling</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{lastFmScrobbleSubtitle(lastFmStatus)}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
</ScrollView>
</Screen>
);
+17
View File
@@ -16,12 +16,15 @@ import {
JetBrainsMono_500Medium,
} from '@expo-google-fonts/jetbrains-mono';
import { usePlaybackSync } from '@/audio/usePlaybackSync';
import { QuickSearchOverlay } from '@/components/search/QuickSearchOverlay';
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
import { useLibraryStore } from '@/stores/libraryStore';
import { useEQStore } from '@/stores/eqStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useNormalizationSync } from '@/audio/useNormalizationSync';
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
import { colors } from '@/theme';
SplashScreen.preventAutoHideAsync();
@@ -44,6 +47,12 @@ function NormalizationSync() {
return null;
}
/** Feeds playback snapshots to the Last.fm scrobble service. Renders nothing. */
function LastFmScrobbler() {
useLastFmScrobbler();
return null;
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter_400Regular,
@@ -82,6 +91,12 @@ export default function RootLayout() {
.getState()
.init()
.catch((err) => console.error('[remoteSources] init failed', err));
// Last.fm: construct the scrobble service + drain any persisted offline queue,
// even if the user never opens the settings screen this session.
useLastFmSettingsStore
.getState()
.init()
.catch((err) => console.error('[lastfm] init failed', err));
}, []);
if (!fontsLoaded) return null;
@@ -93,6 +108,7 @@ export default function RootLayout() {
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
<LastFmScrobbler />
<Stack
screenOptions={{
headerShown: false,
@@ -110,6 +126,7 @@ export default function RootLayout() {
}}
/>
</Stack>
<QuickSearchOverlay />
</SafeAreaProvider>
</GestureHandlerRootView>
);
+408
View File
@@ -0,0 +1,408 @@
import { useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
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 { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import type { LastFmScrobbleProtocol } from '@/types/lastFm';
const PROTOCOL_OPTIONS: {
protocol: LastFmScrobbleProtocol;
label: string;
description: string;
icon: keyof typeof Ionicons.glyphMap;
urlPlaceholder: string;
secretLabel: string;
needsUsername: boolean;
}[] = [
{
protocol: 'lastfm2',
label: 'Last.fm 2.0',
description: 'Libre.fm, GNU FM, and other Last.fm 2.0-compatible servers.',
icon: 'radio-outline',
urlPlaceholder: 'https://libre.fm/2.0/',
secretLabel: 'SESSION KEY',
needsUsername: true,
},
{
protocol: 'audioscrobbler',
label: 'AudioScrobbler',
description: 'Legacy AudioScrobbler 1.2 submission protocol.',
icon: 'git-network-outline',
urlPlaceholder: 'http://post.audioscrobbler.com/',
secretLabel: 'PASSWORD / API KEY',
needsUsername: true,
},
{
protocol: 'listenbrainz',
label: 'ListenBrainz',
description: 'ListenBrainz or a compatible server. Uses an auth token.',
icon: 'headset-outline',
urlPlaceholder: 'https://api.listenbrainz.org',
secretLabel: 'AUTH TOKEN',
needsUsername: false,
},
];
interface FieldProps {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
secureTextEntry?: boolean;
autoCapitalize?: 'none' | 'sentences';
keyboardType?: 'default' | 'url';
}
function Field({
label,
value,
onChangeText,
placeholder,
secureTextEntry,
autoCapitalize = 'none',
keyboardType = 'default',
}: FieldProps) {
return (
<View style={styles.field}>
<Text variant="label" color={colors.textTertiary} style={styles.fieldLabel}>
{label}
</Text>
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textTertiary}
secureTextEntry={secureTextEntry}
autoCapitalize={autoCapitalize}
autoCorrect={false}
keyboardType={keyboardType}
/>
</View>
);
}
export default function LastFmEditScreen() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const status = useLastFmSettingsStore((s) => s.status);
const createCustomProfile = useLastFmSettingsStore((s) => s.createCustomProfile);
const updateCustomProfile = useLastFmSettingsStore((s) => s.updateCustomProfile);
const deleteCustomProfile = useLastFmSettingsStore((s) => s.deleteCustomProfile);
const editing = useMemo(
() => (id ? status?.profiles.find((p) => p.id === id) : undefined),
[id, status]
);
// Wizard: pick a protocol first (new only), then enter destination details.
const [step, setStep] = useState<'type' | 'details'>(editing ? 'details' : 'type');
const [protocol, setProtocol] = useState<LastFmScrobbleProtocol>(editing?.protocol ?? 'lastfm2');
const [name, setName] = useState(editing?.name ?? '');
const [apiBaseUrl, setApiBaseUrl] = useState(editing?.apiBaseUrl ?? '');
const [username, setUsername] = useState(editing?.username ?? '');
const [secret, setSecret] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null);
const meta = PROTOCOL_OPTIONS.find((opt) => opt.protocol === protocol) ?? PROTOCOL_OPTIONS[0];
const canSubmit = name.trim().length > 0 && apiBaseUrl.trim().length > 0;
const chooseProtocol = (next: LastFmScrobbleProtocol) => {
setProtocol(next);
setMessage(null);
setStep('details');
};
const goBack = () => {
// From details on a NEW destination, step back to protocol selection.
if (step === 'details' && !editing) {
setMessage(null);
setStep('type');
return;
}
router.back();
};
const onSave = async () => {
if (busy || !canSubmit) return;
setBusy(true);
setMessage(null);
const input = {
protocol,
name: name.trim(),
apiBaseUrl: apiBaseUrl.trim(),
username: meta.needsUsername ? username.trim() || null : null,
sessionKey: secret.trim() || null,
};
const result = editing
? await updateCustomProfile(editing.id, input)
: await createCustomProfile(input);
// The service returns a status for validation failures (status.lastError set)
// rather than throwing; a clean status (no lastError) means it saved.
if (result && !result.lastError) {
router.back();
return;
}
setMessage({
text: result?.lastError || 'Could not save destination.',
ok: false,
});
setBusy(false);
};
const onRemove = () => {
if (!editing) return;
Alert.alert(
`Remove ${editing.name}?`,
'This deletes the scrobble destination and its queued scrobbles from this device. Your history on the service is unaffected.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => {
void deleteCustomProfile(editing.id);
router.back();
},
},
]
);
};
const backLabel = step === 'details' && !editing ? 'Service' : 'Scrobbling';
return (
<Screen>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.header}>
<Pressable style={styles.back} onPress={goBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
{backLabel}
</Text>
</Pressable>
</View>
{step === 'type' ? (
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<Text variant="title" style={styles.heading}>
Add destination
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.subheading}>
Choose a scrobble service to get started.
</Text>
<View style={styles.typeCards}>
{PROTOCOL_OPTIONS.map((option) => (
<Pressable
key={option.protocol}
style={styles.typeCard}
onPress={() => chooseProtocol(option.protocol)}
accessibilityRole="button"
>
<View style={styles.typeCardIcon}>
<Ionicons name={option.icon} size={24} color={colors.accent} />
</View>
<View style={styles.typeCardText}>
<Text variant="body">{option.label}</Text>
<Text
variant="caption"
color={colors.textSecondary}
style={styles.typeCardDesc}
>
{option.description}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
))}
</View>
</ScrollView>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<Text variant="title" style={styles.heading}>
{editing ? 'Edit destination' : `${meta.label} destination`}
</Text>
<Field
label="NAME"
value={name}
onChangeText={setName}
placeholder={meta.label}
autoCapitalize="sentences"
/>
<Field
label="API URL"
value={apiBaseUrl}
onChangeText={setApiBaseUrl}
placeholder={meta.urlPlaceholder}
keyboardType="url"
/>
{meta.needsUsername ? (
<Field
label="USERNAME"
value={username}
onChangeText={setUsername}
placeholder="username"
/>
) : null}
<Field
label={meta.secretLabel}
value={secret}
onChangeText={setSecret}
placeholder={editing ? 'Unchanged' : meta.secretLabel.toLowerCase()}
secureTextEntry
/>
{message ? (
<Text
variant="caption"
color={message.ok ? colors.accent : colors.warning}
style={styles.message}
>
{message.text}
</Text>
) : null}
<Pressable
style={[styles.saveButton, !canSubmit || busy ? styles.buttonDisabled : null]}
onPress={() => void onSave()}
disabled={!canSubmit || busy}
>
{busy ? (
<ActivityIndicator size="small" color={colors.accentTextStrong} />
) : (
<Text variant="body" color={colors.accentTextStrong}>
{editing ? 'Save' : 'Add destination'}
</Text>
)}
</Pressable>
{editing ? (
<Pressable style={styles.removeButton} onPress={onRemove} accessibilityRole="button">
<Ionicons name="trash-outline" size={18} color={colors.warning} />
<Text variant="body" color={colors.warning}>
Remove destination
</Text>
</Pressable>
) : null}
</ScrollView>
)}
</KeyboardAvoidingView>
</Screen>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.md,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
content: {
paddingBottom: spacing.xxl,
},
heading: {
marginTop: spacing.lg,
marginBottom: spacing.sm,
},
subheading: {
marginBottom: spacing.xl,
},
typeCards: {
gap: spacing.md,
},
typeCard: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
typeCardIcon: {
width: 44,
height: 44,
borderRadius: radius.sm,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
},
typeCardText: {
flex: 1,
gap: 2,
},
typeCardDesc: {
lineHeight: 16,
},
field: {
marginTop: spacing.lg,
},
fieldLabel: {
letterSpacing: 1,
marginBottom: spacing.sm,
},
input: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
color: colors.textPrimary,
fontSize: 15,
},
message: {
marginTop: spacing.lg,
lineHeight: 18,
},
saveButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md + 2,
borderRadius: radius.md,
minHeight: 48,
marginTop: spacing.xl,
backgroundColor: colors.accent,
},
buttonDisabled: {
opacity: 0.5,
},
removeButton: {
flexDirection: 'row',
alignSelf: 'center',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
marginTop: spacing.lg,
},
});
+408
View File
@@ -0,0 +1,408 @@
import { useEffect } from 'react';
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 { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { requestLastFmFlush } from '@/services/lastfm';
import type { LastFmProfileStatus } from '@/types/lastFm';
function profileIcon(profile: LastFmProfileStatus): keyof typeof Ionicons.glyphMap {
if (profile.protocol === 'listenbrainz') return 'headset-outline';
if (profile.kind === 'official') return 'radio-outline';
return 'git-network-outline';
}
function queuedLabel(n: number): string {
return `${n} scrobble${n === 1 ? '' : 's'} queued — will retry`;
}
/** Subtitle for a custom destination row (username is shown inline for official). */
function customStatusLine(profile: LastFmProfileStatus): { text: string; tone: 'normal' | 'error' } {
if (profile.connected) {
const who = profile.username ? `Connected as ${profile.username}` : 'Token configured';
// Queued scrobbles take priority over a transient error — they're cached, not lost.
if (profile.pendingScrobbles > 0) return { text: `${who} · ${queuedLabel(profile.pendingScrobbles)}`, tone: 'normal' };
if (profile.lastError) return { text: profile.lastError, tone: 'error' };
return { text: `${who}${profile.enabled ? '' : ' · paused'}`, tone: 'normal' };
}
if (profile.lastError) return { text: profile.lastError, tone: 'error' };
return { text: 'Needs credentials — tap to fix', tone: 'normal' };
}
export default function LastFmScreen() {
const router = useRouter();
const status = useLastFmSettingsStore((s) => s.status);
const authHint = useLastFmSettingsStore((s) => s.authHint);
const errorMessage = useLastFmSettingsStore((s) => s.errorMessage);
const init = useLastFmSettingsStore((s) => s.init);
const setEnabled = useLastFmSettingsStore((s) => s.setEnabled);
const beginAuth = useLastFmSettingsStore((s) => s.beginAuth);
const setProfileEnabled = useLastFmSettingsStore((s) => s.setProfileEnabled);
const disconnectProfile = useLastFmSettingsStore((s) => s.disconnectProfile);
useEffect(() => {
void init();
}, [init]);
const profiles = status?.profiles ?? [];
const connectOfficial = (profile: LastFmProfileStatus) => {
if (status && !status.hasApiCredentials) {
Alert.alert(
'Last.fm not configured',
'This build has no Last.fm API key. Set EXPO_PUBLIC_LASTFM_API_KEY / _SHARED_SECRET, or add a custom Last.fm-compatible / ListenBrainz destination instead.'
);
return;
}
void beginAuth(profile.id);
};
const confirmDisconnect = (profile: LastFmProfileStatus) => {
Alert.alert(`Disconnect ${profile.name}?`, 'Astra will stop scrobbling to this destination.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disconnect',
style: 'destructive',
onPress: () => void disconnectProfile(profile.id),
},
]);
};
const renderOfficial = (profile: LastFmProfileStatus) => {
const subtitle = profile.connected
? profile.pendingScrobbles > 0
? queuedLabel(profile.pendingScrobbles)
: profile.lastError
? profile.lastError
: 'Scrobbling enabled'
: profile.requiresApiCredentials && status && !status.hasApiCredentials
? 'Last.fm API key not set in this build'
: 'Not connected';
const subtitleError = profile.pendingScrobbles === 0 && !!profile.lastError;
return (
<View key={profile.id} style={styles.row}>
<View style={styles.rowIcon}>
<Ionicons name={profileIcon(profile)} size={20} color={colors.accent} />
</View>
<View style={styles.rowMeta}>
<View style={styles.rowTitleLine}>
<Text variant="body" numberOfLines={1} style={styles.rowName}>
{profile.name}
</Text>
<Text variant="label" color={colors.textTertiary}>
{profile.protocolLabel}
</Text>
</View>
<Text
variant="caption"
color={subtitleError ? colors.warning : colors.textSecondary}
numberOfLines={2}
>
{subtitle}
</Text>
</View>
{profile.connected ? (
<View style={styles.linkedRight}>
{profile.username ? (
<Text variant="caption" color={colors.accentText} numberOfLines={1} style={styles.linkedUser}>
{profile.username}
</Text>
) : null}
<Pressable
onPress={() => confirmDisconnect(profile)}
hitSlop={8}
accessibilityLabel="Disconnect Last.fm"
>
<Ionicons name="close-circle" size={22} color={colors.textTertiary} />
</Pressable>
</View>
) : (
<Pressable
style={styles.connectButton}
onPress={() => connectOfficial(profile)}
accessibilityRole="button"
>
<Ionicons name="link" size={16} color={colors.accentTextStrong} />
<Text variant="label" color={colors.accentTextStrong}>
Connect
</Text>
</Pressable>
)}
</View>
);
};
const renderCustom = (profile: LastFmProfileStatus) => {
const line = customStatusLine(profile);
return (
<Pressable
key={profile.id}
style={styles.row}
onPress={() => router.push({ pathname: '/lastfm/edit', params: { id: profile.id } })}
accessibilityRole="button"
>
<View style={styles.rowIcon}>
<Ionicons name={profileIcon(profile)} size={20} color={colors.accent} />
</View>
<View style={styles.rowMeta}>
<View style={styles.rowTitleLine}>
<Text variant="body" numberOfLines={1} style={styles.rowName}>
{profile.name}
</Text>
<Text variant="label" color={colors.textTertiary}>
{profile.protocolLabel}
</Text>
</View>
<Text
variant="caption"
color={line.tone === 'error' ? colors.warning : colors.textSecondary}
numberOfLines={2}
>
{line.text}
</Text>
</View>
{profile.connected ? (
<Switch
value={profile.enabled}
onValueChange={(v) => void setProfileEnabled(profile.id, v)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
) : (
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
)}
</Pressable>
);
};
return (
<Screen>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Settings
</Text>
</Pressable>
<Pressable
onPress={() => router.push('/lastfm/edit')}
hitSlop={8}
accessibilityLabel="Add destination"
>
<Ionicons name="add" size={26} color={colors.accent} />
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
Scrobbling
</Text>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text variant="body">Enable scrobbling</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.description}>
Submit played tracks + &quot;now playing&quot; to your connected destinations.
</Text>
</View>
<Switch
value={status?.enabled ?? false}
onValueChange={(v) => void setEnabled(v)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
{status?.statusMessage ? (
<Text variant="caption" color={colors.textSecondary} style={styles.statusMessage}>
{status.statusMessage}
</Text>
) : null}
{authHint ? (
<Text variant="caption" color={colors.accent} style={styles.statusMessage}>
{authHint}
</Text>
) : null}
{errorMessage ? (
<Text variant="caption" color={colors.warning} style={styles.statusMessage}>
{errorMessage}
</Text>
) : null}
{status && status.pendingScrobbles > 0 ? (
<Pressable style={styles.retryButton} onPress={() => requestLastFmFlush()}>
<Ionicons name="sync" size={16} color={colors.accentText} />
<Text variant="label" color={colors.accentText}>
Retry {status.pendingScrobbles} queued now
</Text>
</Pressable>
) : null}
<Text
variant="label"
color={colors.textTertiary}
style={[styles.sectionLabel, styles.sectionSpacing]}
>
DESTINATIONS
</Text>
<View style={styles.list}>
{profiles.map((profile) =>
profile.kind === 'official' ? renderOfficial(profile) : renderCustom(profile)
)}
</View>
<Pressable style={styles.addButton} onPress={() => router.push('/lastfm/edit')}>
<Ionicons name="add" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Add destination
</Text>
</Pressable>
<Text variant="caption" color={colors.textTertiary} style={styles.footnote}>
A scrobble is sent once a track plays past half its length (or 4 minutes). Tracks under 30
seconds are skipped. Failed scrobbles queue offline and retry automatically.
</Text>
</ScrollView>
</Screen>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.md,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
heading: {
marginTop: spacing.lg,
marginBottom: spacing.lg,
},
content: {
paddingBottom: spacing.xxl,
},
card: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
toggleText: {
flex: 1,
gap: 2,
},
description: {
lineHeight: 16,
},
statusMessage: {
marginTop: spacing.md,
lineHeight: 18,
},
retryButton: {
flexDirection: 'row',
alignSelf: 'flex-start',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
marginTop: spacing.md,
},
sectionLabel: {
letterSpacing: 1,
marginBottom: spacing.sm,
},
sectionSpacing: {
marginTop: spacing.xxl,
},
list: {
gap: spacing.sm,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
rowIcon: {
width: 36,
height: 36,
borderRadius: radius.sm,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
},
rowMeta: {
flex: 1,
gap: 2,
},
rowTitleLine: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
},
rowName: {
flex: 1,
},
linkedRight: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
maxWidth: 150,
},
linkedUser: {
flexShrink: 1,
},
connectButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
backgroundColor: colors.accent,
},
addButton: {
flexDirection: 'row',
alignSelf: 'flex-start',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
marginTop: spacing.lg,
},
footnote: {
marginTop: spacing.xl,
lineHeight: 16,
},
});