mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
port desktop's search engine
This commit is contained in:
+4
-2
@@ -30,8 +30,10 @@ yarn-error.*
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
# local env files (keep .env.example tracked; it carries no secrets)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"android:release": "rm -rf android/app/build/generated/assets/react && ORG_GRADLE_PROJECT_reactNativeArchitectures=arm64-v8a expo run:android --variant release",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 & 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 + "now playing" 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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import {
|
||||
initLastFmService,
|
||||
publishLastFmSnapshot,
|
||||
requestLastFmFlush,
|
||||
} from '@/services/lastfm';
|
||||
import type { ScrobbleSnapshot } from '@/services/lastfm/scrobbleService';
|
||||
|
||||
/**
|
||||
* Feeds the Last.fm scrobble service from `playerStore`. The store's `currentTime`
|
||||
* is refreshed ~every 500ms by `usePlaybackSync` (useProgress), so this publishes a
|
||||
* snapshot on every progress tick / track change / state change — exactly the cadence
|
||||
* the desktop service's timing state machine expects. Mount once near the root.
|
||||
*/
|
||||
export function useLastFmScrobbler(): void {
|
||||
const currentTrack = usePlayerStore((s) => s.currentTrack);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
|
||||
// Construct the service once (drains the offline queue on launch). Idempotent.
|
||||
useEffect(() => {
|
||||
void initLastFmService().catch((err) => {
|
||||
console.warn('[lastfm] service init failed', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot: ScrobbleSnapshot = {
|
||||
playbackState,
|
||||
currentTime,
|
||||
// Prefer the track's metadata duration — it's known the instant the track is
|
||||
// active, whereas RNTP's progress duration is 0 for the first moments of a
|
||||
// track. The scrobble threshold (half the duration) depends on this.
|
||||
duration: currentTrack && currentTrack.duration > 0 ? currentTrack.duration : duration,
|
||||
currentTrack: currentTrack
|
||||
? {
|
||||
path: currentTrack.path ?? null,
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist,
|
||||
artistNames: currentTrack.artistNames,
|
||||
album: currentTrack.album ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
publishLastFmSnapshot(snapshot);
|
||||
}, [currentTrack, currentTime, duration, playbackState]);
|
||||
|
||||
// Returning to the foreground (or regaining connectivity) is the natural moment
|
||||
// to retry any queued offline scrobbles. NetInfo isn't a dependency; the service's
|
||||
// own retry timer covers the rest.
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') requestLastFmFlush();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
const WORD_BOUNDARY_SEPARATORS = new Set([
|
||||
' ',
|
||||
'\t',
|
||||
'-',
|
||||
'_',
|
||||
'/',
|
||||
'.',
|
||||
',',
|
||||
':',
|
||||
';',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
'"',
|
||||
"'",
|
||||
]);
|
||||
|
||||
function normalizeSearchValue(value: string): string {
|
||||
return value.toLocaleLowerCase().trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function isWordBoundary(value: string, index: number): boolean {
|
||||
if (index <= 0) return true;
|
||||
return WORD_BOUNDARY_SEPARATORS.has(value[index - 1]);
|
||||
}
|
||||
|
||||
export function fuzzyScore(queryInput: string, candidateInput: string): number | null {
|
||||
const query = normalizeSearchValue(queryInput);
|
||||
const candidate = normalizeSearchValue(candidateInput);
|
||||
|
||||
if (!query || !candidate) return null;
|
||||
|
||||
let queryIndex = 0;
|
||||
let firstMatchIndex = -1;
|
||||
let lastMatchIndex = -1;
|
||||
let previousMatchIndex = -2;
|
||||
let contiguousMatches = 0;
|
||||
let boundaryMatches = 0;
|
||||
let score = 0;
|
||||
|
||||
for (let candidateIndex = 0; candidateIndex < candidate.length; candidateIndex += 1) {
|
||||
if (candidate[candidateIndex] !== query[queryIndex]) continue;
|
||||
|
||||
if (firstMatchIndex === -1) {
|
||||
firstMatchIndex = candidateIndex;
|
||||
}
|
||||
|
||||
const contiguous = candidateIndex === previousMatchIndex + 1;
|
||||
const boundary = isWordBoundary(candidate, candidateIndex);
|
||||
|
||||
if (queryIndex === 0) {
|
||||
if (candidateIndex === 0) {
|
||||
score += 20;
|
||||
} else if (boundary) {
|
||||
score += 12;
|
||||
}
|
||||
}
|
||||
|
||||
if (boundary) boundaryMatches += 1;
|
||||
if (contiguous) contiguousMatches += 1;
|
||||
|
||||
previousMatchIndex = candidateIndex;
|
||||
lastMatchIndex = candidateIndex;
|
||||
queryIndex += 1;
|
||||
|
||||
if (queryIndex === query.length) break;
|
||||
}
|
||||
|
||||
if (queryIndex !== query.length || firstMatchIndex < 0 || lastMatchIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const span = lastMatchIndex - firstMatchIndex + 1;
|
||||
score += query.length * 8;
|
||||
score += contiguousMatches * 5;
|
||||
score += boundaryMatches * 4;
|
||||
score += Math.max(0, 16 - span);
|
||||
score += Math.max(0, 10 - firstMatchIndex);
|
||||
score += Math.max(0, 10 - (candidate.length - query.length));
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export interface FieldDef {
|
||||
value: string | null | undefined;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export const MIN_SCORE_THRESHOLD = 25;
|
||||
|
||||
export function multiFieldScore(queryInput: string, fields: FieldDef[]): number | null {
|
||||
const normalizedQuery = normalizeSearchValue(queryInput);
|
||||
if (!normalizedQuery) return null;
|
||||
|
||||
let bestScore: number | null = null;
|
||||
|
||||
for (const field of fields) {
|
||||
const value = field.value ?? '';
|
||||
const normalizedValue = normalizeSearchValue(value);
|
||||
if (!normalizedValue) continue;
|
||||
|
||||
let fieldScore = fuzzyScore(queryInput, value);
|
||||
if (fieldScore === null) continue;
|
||||
|
||||
if (normalizedValue === normalizedQuery) {
|
||||
fieldScore += 60;
|
||||
}
|
||||
|
||||
if (normalizedValue.includes(normalizedQuery)) {
|
||||
fieldScore += 30;
|
||||
}
|
||||
|
||||
if (normalizedValue.startsWith(normalizedQuery)) {
|
||||
fieldScore += 20;
|
||||
}
|
||||
|
||||
const words = normalizedValue.split(/\s+/);
|
||||
if (words.some((word) => word.startsWith(normalizedQuery))) {
|
||||
fieldScore += 15;
|
||||
}
|
||||
|
||||
fieldScore = Math.round(fieldScore * field.weight);
|
||||
|
||||
if (bestScore === null || fieldScore > bestScore) {
|
||||
bestScore = fieldScore;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestScore === null) return null;
|
||||
|
||||
if (normalizedQuery.length <= 2) {
|
||||
const hasStrictMatch = fields.some((field) => {
|
||||
const normalizedValue = normalizeSearchValue(field.value ?? '');
|
||||
if (!normalizedValue) return false;
|
||||
if (normalizedValue.startsWith(normalizedQuery)) return true;
|
||||
return normalizedValue.split(/\s+/).some((word) => word.startsWith(normalizedQuery));
|
||||
});
|
||||
if (!hasStrictMatch) return null;
|
||||
}
|
||||
|
||||
return bestScore;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Persistence for the Last.fm service config. The desktop service serializes the
|
||||
// whole `LastFmServiceConfig` (including each profile's offline queue) to a JSON
|
||||
// config file via its `onConfigChange` callback. On mobile we do the same, but:
|
||||
// - the config JSON (profiles + pending scrobbles + flags) → settings KV table
|
||||
// - each profile's `sessionKey` → expo-secure-store (stripped from the JSON)
|
||||
// On load we re-attach the session keys before handing the config to the service,
|
||||
// so the ported service code (which reads `profile.sessionKey`) is unchanged.
|
||||
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import type { LastFmServiceConfig } from '@/types/lastFm';
|
||||
import {
|
||||
deleteLastFmSessionKey,
|
||||
getLastFmSessionKey,
|
||||
setLastFmSessionKey,
|
||||
} from './credentials';
|
||||
|
||||
const CONFIG_KEY = 'lastfm_config';
|
||||
// Tracks which profile ids currently hold a secret, so a removed profile's
|
||||
// session key can be purged from secure-store on the next persist.
|
||||
const SECRET_IDS_KEY = 'lastfm_secret_profile_ids';
|
||||
|
||||
function parseStringArray(value: string | null): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the persisted config (with session keys re-attached), or null if none. */
|
||||
export async function loadLastFmConfig(): Promise<LastFmServiceConfig | null> {
|
||||
const db = await openLibraryDb();
|
||||
const json = await getSetting(db, CONFIG_KEY);
|
||||
if (!json) return null;
|
||||
|
||||
let parsed: LastFmServiceConfig;
|
||||
try {
|
||||
parsed = JSON.parse(json) as LastFmServiceConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || !Array.isArray(parsed.profiles)) return null;
|
||||
|
||||
await Promise.all(
|
||||
parsed.profiles.map(async (profile) => {
|
||||
if (profile && typeof profile.id === 'string') {
|
||||
profile.sessionKey = await getLastFmSessionKey(profile.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Persist the config: secrets to secure-store, everything else to the settings KV. */
|
||||
export async function persistLastFmConfig(config: LastFmServiceConfig): Promise<void> {
|
||||
const db = await openLibraryDb();
|
||||
|
||||
const previousSecretIds = parseStringArray(await getSetting(db, SECRET_IDS_KEY));
|
||||
const currentSecretIds: string[] = [];
|
||||
|
||||
for (const profile of config.profiles) {
|
||||
if (profile.sessionKey) {
|
||||
await setLastFmSessionKey(profile.id, profile.sessionKey);
|
||||
currentSecretIds.push(profile.id);
|
||||
} else {
|
||||
await deleteLastFmSessionKey(profile.id);
|
||||
}
|
||||
}
|
||||
// Purge secrets for profiles that no longer exist (e.g. deleted custom profile).
|
||||
for (const id of previousSecretIds) {
|
||||
if (!config.profiles.some((profile) => profile.id === id)) {
|
||||
await deleteLastFmSessionKey(id);
|
||||
}
|
||||
}
|
||||
await setSetting(db, SECRET_IDS_KEY, JSON.stringify(currentSecretIds));
|
||||
|
||||
const sanitized: LastFmServiceConfig = {
|
||||
enabled: config.enabled,
|
||||
activeProfileId: config.activeProfileId,
|
||||
profiles: config.profiles.map((profile) => ({
|
||||
...profile,
|
||||
sessionKey: null, // never written to plaintext SQLite
|
||||
pendingScrobbles: profile.pendingScrobbles.map((item) => ({ ...item })),
|
||||
})),
|
||||
};
|
||||
await setSetting(db, CONFIG_KEY, JSON.stringify(sanitized));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Last.fm API credentials. Desktop reads these from `LASTFM_API_KEY` /
|
||||
// `LASTFM_SHARED_SECRET` env vars (src/main/index.ts). Expo inlines `EXPO_PUBLIC_*`
|
||||
// vars at build time, so put your registered Last.fm API application's key + secret
|
||||
// in a (gitignored) `.env` file — see `.env.example`. Without them, the official
|
||||
// Last.fm protocol is disabled (custom/AudioScrobbler/ListenBrainz still work, as
|
||||
// they sign with their own credentials).
|
||||
//
|
||||
// Note: like every Last.fm desktop/mobile client, the "shared secret" ships inside
|
||||
// the app. Last.fm's auth model accepts this — the session key (obtained per user
|
||||
// via browser approval) is what authorizes scrobbles, and it lives in secure-store.
|
||||
|
||||
export const LASTFM_API_KEY = (process.env.EXPO_PUBLIC_LASTFM_API_KEY ?? '').trim();
|
||||
export const LASTFM_SHARED_SECRET = (process.env.EXPO_PUBLIC_LASTFM_SHARED_SECRET ?? '').trim();
|
||||
@@ -0,0 +1,25 @@
|
||||
// Per-profile Last.fm session keys / tokens live in the Android Keystore
|
||||
// (expo-secure-store), keyed by profile id — mirroring the M5 remote-source
|
||||
// password pattern (src/services/remoteCredentials.ts). The rest of the scrobble
|
||||
// config (profiles, offline queue) is plain JSON in the settings table; only the
|
||||
// secret leaves SQLite.
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
function secretKey(profileId: string): string {
|
||||
// SecureStore keys must be alphanumeric + ".-_" — sanitize the profile id.
|
||||
const safe = profileId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return `lastfm_session_${safe}`;
|
||||
}
|
||||
|
||||
export async function getLastFmSessionKey(profileId: string): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(secretKey(profileId));
|
||||
}
|
||||
|
||||
export async function setLastFmSessionKey(profileId: string, sessionKey: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(secretKey(profileId), sessionKey);
|
||||
}
|
||||
|
||||
export async function deleteLastFmSessionKey(profileId: string): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(secretKey(profileId));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Module-singleton wiring for the Last.fm scrobble service. Replaces the desktop
|
||||
// main/index.ts wiring (env key/secret, shell.openExternal, config persistence,
|
||||
// status broadcast) — but in-process, since mobile has no main/renderer split.
|
||||
//
|
||||
// The settings store registers a status listener via `setLastFmStatusListener`;
|
||||
// the feed hook (useLastFmScrobbler) calls `publishLastFmSnapshot` / `requestLastFmFlush`.
|
||||
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import {
|
||||
LASTFM_OFFICIAL_PROFILE_ID,
|
||||
type LastFmServiceConfig,
|
||||
type LastFmStatus,
|
||||
} from '@/types/lastFm';
|
||||
import { LASTFM_API_KEY, LASTFM_SHARED_SECRET } from './constants';
|
||||
import { loadLastFmConfig, persistLastFmConfig } from './config';
|
||||
import { LastFmService, type ScrobbleSnapshot } from './scrobbleService';
|
||||
|
||||
let service: LastFmService | null = null;
|
||||
let initPromise: Promise<LastFmService> | null = null;
|
||||
let statusListener: ((status: LastFmStatus) => void) | null = null;
|
||||
let lastStatus: LastFmStatus | null = null;
|
||||
|
||||
const DEFAULT_CONFIG: LastFmServiceConfig = {
|
||||
enabled: false,
|
||||
activeProfileId: LASTFM_OFFICIAL_PROFILE_ID,
|
||||
profiles: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Register the single status listener (the settings store). Immediately replays
|
||||
* the most recent status so a late subscriber isn't stuck on null.
|
||||
*/
|
||||
export function setLastFmStatusListener(fn: ((status: LastFmStatus) => void) | null): void {
|
||||
statusListener = fn;
|
||||
if (fn && lastStatus) fn(lastStatus);
|
||||
}
|
||||
|
||||
/** Construct + start the service once, loading persisted config. Idempotent. */
|
||||
export function initLastFmService(): Promise<LastFmService> {
|
||||
if (service) return Promise.resolve(service);
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
const stored = await loadLastFmConfig().catch(() => null);
|
||||
const instance = new LastFmService({
|
||||
config: stored ?? DEFAULT_CONFIG,
|
||||
apiKey: LASTFM_API_KEY,
|
||||
sharedSecret: LASTFM_SHARED_SECRET,
|
||||
// Fire-and-forget: openBrowserAsync resolves only when the tab is dismissed,
|
||||
// so don't await it — beginAuth must return immediately to start auth polling.
|
||||
openExternal: async (url: string) => {
|
||||
void WebBrowser.openBrowserAsync(url);
|
||||
},
|
||||
onConfigChange: async (config) => {
|
||||
await persistLastFmConfig(config);
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
lastStatus = status;
|
||||
statusListener?.(status);
|
||||
},
|
||||
});
|
||||
service = instance;
|
||||
lastStatus = instance.getStatus();
|
||||
instance.start(); // drain any persisted offline queue on launch
|
||||
return instance;
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/** The live service. Throws if accessed before `initLastFmService` resolves. */
|
||||
export function getLastFmService(): LastFmService {
|
||||
if (!service) {
|
||||
throw new Error('Last.fm service not initialized — call initLastFmService() first.');
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
/** Feed a playback snapshot to the timing state machine (no-op until initialized). */
|
||||
export function publishLastFmSnapshot(snapshot: ScrobbleSnapshot | null): void {
|
||||
service?.publishSnapshot(snapshot);
|
||||
}
|
||||
|
||||
/** Ask the service to attempt an offline-queue flush now (foreground/connectivity resume). */
|
||||
export function requestLastFmFlush(): void {
|
||||
service?.requestFlush();
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,335 @@
|
||||
// Last.fm settings store — ported from desktop
|
||||
// `src/renderer/stores/lastFmSettingsStore.ts`. The browser-auth polling loop is
|
||||
// kept verbatim; the only change is that `window.electronAPI.lastFm.*` IPC calls
|
||||
// become direct calls into the in-process scrobble service singleton.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
LastFmAuthFinishResult,
|
||||
LastFmAuthStartResult,
|
||||
LastFmCustomProfileInput,
|
||||
LastFmStatus,
|
||||
} from '@/types/lastFm';
|
||||
import {
|
||||
getLastFmService,
|
||||
initLastFmService,
|
||||
setLastFmStatusListener,
|
||||
} from '@/services/lastfm';
|
||||
|
||||
const LASTFM_AUTH_POLL_INTERVAL_MS = 2_000;
|
||||
const LASTFM_AUTH_POLL_TIMEOUT_MS = 2 * 60 * 1_000;
|
||||
|
||||
interface LastFmSettingsStore {
|
||||
status: LastFmStatus | null;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
isAuthorizing: boolean;
|
||||
errorMessage: string;
|
||||
authHint: string;
|
||||
init: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
setEnabled: (enabled: boolean) => Promise<LastFmStatus | null>;
|
||||
createCustomProfile: (input: LastFmCustomProfileInput) => Promise<LastFmStatus | null>;
|
||||
updateCustomProfile: (
|
||||
profileId: string,
|
||||
input: LastFmCustomProfileInput
|
||||
) => Promise<LastFmStatus | null>;
|
||||
deleteCustomProfile: (profileId: string) => Promise<LastFmStatus | null>;
|
||||
setProfileEnabled: (profileId: string, enabled: boolean) => Promise<LastFmStatus | null>;
|
||||
beginAuth: (profileId: string) => Promise<LastFmAuthStartResult | null>;
|
||||
finishAuth: () => Promise<LastFmAuthFinishResult | null>;
|
||||
disconnectProfile: (profileId: string) => Promise<LastFmStatus | null>;
|
||||
resetToDefaults: () => Promise<LastFmStatus | null>;
|
||||
}
|
||||
|
||||
let statusSubscribed = false;
|
||||
let authPollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let authPollInFlight = false;
|
||||
let authPollDeadlineMs = 0;
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return 'Failed to update Last.fm settings.';
|
||||
}
|
||||
|
||||
function buildDefaultAuthHint(status: LastFmStatus): string {
|
||||
if (!status.authPending) return '';
|
||||
return 'Approve Astra in your browser tab. Connection will complete automatically.';
|
||||
}
|
||||
|
||||
export const useLastFmSettingsStore = create<LastFmSettingsStore>((set, get) => {
|
||||
const stopAuthPolling = (): void => {
|
||||
if (authPollTimer) {
|
||||
clearTimeout(authPollTimer);
|
||||
authPollTimer = null;
|
||||
}
|
||||
authPollInFlight = false;
|
||||
authPollDeadlineMs = 0;
|
||||
if (get().isAuthorizing) {
|
||||
set({ isAuthorizing: false });
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleAuthPoll = (delayMs: number): void => {
|
||||
if (authPollTimer) {
|
||||
clearTimeout(authPollTimer);
|
||||
}
|
||||
authPollTimer = setTimeout(() => {
|
||||
authPollTimer = null;
|
||||
void pollAuthCompletion();
|
||||
}, Math.max(0, delayMs));
|
||||
};
|
||||
|
||||
const applyStatus = (status: LastFmStatus): LastFmStatus => {
|
||||
if (!status.authPending) {
|
||||
stopAuthPolling();
|
||||
}
|
||||
|
||||
set({
|
||||
status,
|
||||
errorMessage: '',
|
||||
authHint: buildDefaultAuthHint(status),
|
||||
});
|
||||
return status;
|
||||
};
|
||||
|
||||
const ensureSubscription = (): void => {
|
||||
if (statusSubscribed) return;
|
||||
statusSubscribed = true;
|
||||
setLastFmStatusListener((status) => {
|
||||
applyStatus(status);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchStatus = async (): Promise<LastFmStatus> => {
|
||||
const service = await initLastFmService();
|
||||
ensureSubscription();
|
||||
return applyStatus(service.getStatus());
|
||||
};
|
||||
|
||||
const pollAuthCompletion = async (): Promise<void> => {
|
||||
if (authPollInFlight) return;
|
||||
|
||||
authPollInFlight = true;
|
||||
try {
|
||||
const result = await getLastFmService().finishAuth();
|
||||
const status = await fetchStatus().catch(() => null);
|
||||
|
||||
if (result.ok) {
|
||||
stopAuthPolling();
|
||||
set({ authHint: '', errorMessage: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stillPending = status?.authPending ?? false;
|
||||
if (stillPending) {
|
||||
if (Date.now() >= authPollDeadlineMs) {
|
||||
stopAuthPolling();
|
||||
set({
|
||||
authHint: 'Authorization still pending. Approve Astra on Last.fm, then press Connect again.',
|
||||
errorMessage: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
authHint: 'Waiting for Last.fm approval in your browser...',
|
||||
errorMessage: '',
|
||||
});
|
||||
scheduleAuthPoll(LASTFM_AUTH_POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
stopAuthPolling();
|
||||
set({
|
||||
authHint: '',
|
||||
errorMessage: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = await fetchStatus().catch(() => null);
|
||||
const stillPending = status?.authPending ?? false;
|
||||
|
||||
if (stillPending && Date.now() < authPollDeadlineMs) {
|
||||
set({
|
||||
authHint: 'Waiting for Last.fm approval in your browser...',
|
||||
errorMessage: '',
|
||||
});
|
||||
scheduleAuthPoll(LASTFM_AUTH_POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
stopAuthPolling();
|
||||
set({ errorMessage: toErrorMessage(error), authHint: '' });
|
||||
} finally {
|
||||
authPollInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startAuthPolling = (): void => {
|
||||
stopAuthPolling();
|
||||
authPollDeadlineMs = Date.now() + LASTFM_AUTH_POLL_TIMEOUT_MS;
|
||||
set({
|
||||
isAuthorizing: true,
|
||||
authHint: 'Waiting for Last.fm approval in your browser...',
|
||||
errorMessage: '',
|
||||
});
|
||||
scheduleAuthPoll(1_000);
|
||||
};
|
||||
|
||||
return {
|
||||
status: null,
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
isAuthorizing: false,
|
||||
errorMessage: '',
|
||||
authHint: '',
|
||||
|
||||
init: async () => {
|
||||
if (get().isInitialized) return;
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
} finally {
|
||||
set({ isLoading: false, isInitialized: true });
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled: async (enabled: boolean) => {
|
||||
try {
|
||||
const status = await getLastFmService().setEnabled(enabled);
|
||||
return applyStatus(status);
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
createCustomProfile: async (input: LastFmCustomProfileInput) => {
|
||||
try {
|
||||
const status = await getLastFmService().createCustomProfile(input);
|
||||
return applyStatus(status);
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateCustomProfile: async (profileId: string, input: LastFmCustomProfileInput) => {
|
||||
try {
|
||||
const status = await getLastFmService().updateCustomProfile(profileId, input);
|
||||
return applyStatus(status);
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteCustomProfile: async (profileId: string) => {
|
||||
try {
|
||||
const status = await getLastFmService().deleteCustomProfile(profileId);
|
||||
return applyStatus(status);
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setProfileEnabled: async (profileId: string, enabled: boolean) => {
|
||||
try {
|
||||
const status = await getLastFmService().setProfileEnabled(profileId, enabled);
|
||||
return applyStatus(status);
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
beginAuth: async (profileId: string) => {
|
||||
try {
|
||||
const result = await getLastFmService().beginAuth(profileId);
|
||||
const status = await fetchStatus().catch(() => null);
|
||||
if (result.ok && (status?.authPending ?? result.authPending)) {
|
||||
startAuthPolling();
|
||||
} else if (result.ok) {
|
||||
stopAuthPolling();
|
||||
set({ errorMessage: '', authHint: status ? buildDefaultAuthHint(status) : '' });
|
||||
} else {
|
||||
stopAuthPolling();
|
||||
set({
|
||||
errorMessage: result.message,
|
||||
authHint: status ? buildDefaultAuthHint(status) : '',
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
stopAuthPolling();
|
||||
set({ errorMessage: toErrorMessage(error), authHint: '' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
finishAuth: async () => {
|
||||
try {
|
||||
const result = await getLastFmService().finishAuth();
|
||||
const status = await fetchStatus().catch(() => null);
|
||||
if (result.ok) {
|
||||
stopAuthPolling();
|
||||
set({ authHint: '', errorMessage: '' });
|
||||
} else if (status?.authPending) {
|
||||
set({
|
||||
authHint: 'Waiting for Last.fm approval in your browser...',
|
||||
errorMessage: '',
|
||||
});
|
||||
} else {
|
||||
stopAuthPolling();
|
||||
set({
|
||||
errorMessage: result.message,
|
||||
authHint: status ? buildDefaultAuthHint(status) : '',
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
stopAuthPolling();
|
||||
set({ errorMessage: toErrorMessage(error), authHint: '' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
disconnectProfile: async (profileId: string) => {
|
||||
try {
|
||||
stopAuthPolling();
|
||||
const status = await getLastFmService().disconnectProfile(profileId);
|
||||
set({ authHint: '' });
|
||||
return applyStatus(status);
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
resetToDefaults: async () => {
|
||||
try {
|
||||
stopAuthPolling();
|
||||
const status = await getLastFmService().resetToDefaults();
|
||||
set({ authHint: '' });
|
||||
return applyStatus(status);
|
||||
} catch (error) {
|
||||
set({ errorMessage: toErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SearchStore {
|
||||
isQuickSearchOpen: boolean;
|
||||
initialQuery: string;
|
||||
openVersion: number;
|
||||
openQuickSearch: (initialQuery?: string) => void;
|
||||
closeQuickSearch: () => void;
|
||||
}
|
||||
|
||||
export const useSearchStore = create<SearchStore>((set) => ({
|
||||
isQuickSearchOpen: false,
|
||||
initialQuery: '',
|
||||
openVersion: 0,
|
||||
openQuickSearch: (initialQuery = '') =>
|
||||
set((state) => ({
|
||||
isQuickSearchOpen: true,
|
||||
initialQuery,
|
||||
openVersion: state.openVersion + 1,
|
||||
})),
|
||||
closeQuickSearch: () => set({ isQuickSearchOpen: false }),
|
||||
}));
|
||||
@@ -0,0 +1,172 @@
|
||||
// Last.fm / scrobbling data model — ported VERBATIM from desktop
|
||||
// `src/types/lastFm.ts`. Pure types + URL/protocol helpers, no Node deps, so the
|
||||
// scrobble service (src/services/lastfm) shares the exact same contract as desktop.
|
||||
|
||||
export const LASTFM_OFFICIAL_API_BASE_URL = 'https://ws.audioscrobbler.com/2.0/';
|
||||
export const LASTFM_OFFICIAL_PROFILE_ID = 'official-lastfm';
|
||||
|
||||
export type LastFmProfileKind = 'official' | 'custom';
|
||||
export type LastFmScrobbleProtocol = 'lastfm2' | 'audioscrobbler' | 'listenbrainz';
|
||||
|
||||
export interface LastFmPendingScrobble {
|
||||
id: string;
|
||||
trackPath: string | null;
|
||||
track: string;
|
||||
artist: string;
|
||||
artistNames?: string[];
|
||||
album: string | null;
|
||||
albumArtist: string | null;
|
||||
durationSeconds: number | null;
|
||||
timestamp: number;
|
||||
queuedAt: number;
|
||||
retryCount: number;
|
||||
nextRetryAt: number;
|
||||
}
|
||||
|
||||
export interface LastFmProfileConfig {
|
||||
id: string;
|
||||
kind: LastFmProfileKind;
|
||||
protocol: LastFmScrobbleProtocol;
|
||||
name: string;
|
||||
apiBaseUrl: string;
|
||||
enabled: boolean;
|
||||
sessionKey: string | null;
|
||||
username: string | null;
|
||||
pendingScrobbles: LastFmPendingScrobble[];
|
||||
}
|
||||
|
||||
export interface LastFmProfileStatus {
|
||||
id: string;
|
||||
kind: LastFmProfileKind;
|
||||
protocol: LastFmScrobbleProtocol;
|
||||
protocolLabel: string;
|
||||
name: string;
|
||||
apiBaseUrl: string;
|
||||
enabled: boolean;
|
||||
username: string | null;
|
||||
connected: boolean;
|
||||
active: boolean;
|
||||
pendingScrobbles: number;
|
||||
canDelete: boolean;
|
||||
requiresApiCredentials: boolean;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface LastFmServiceConfig {
|
||||
enabled: boolean;
|
||||
activeProfileId: string;
|
||||
profiles: LastFmProfileConfig[];
|
||||
}
|
||||
|
||||
export interface LastFmStatus {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
username: string | null;
|
||||
apiBaseUrl: string;
|
||||
usingCustomEndpoint: boolean;
|
||||
activeProfileId: string;
|
||||
activeProfile: LastFmProfileStatus;
|
||||
profiles: LastFmProfileStatus[];
|
||||
authPending: boolean;
|
||||
authPendingProfileId: string | null;
|
||||
pendingScrobbles: number;
|
||||
hasApiCredentials: boolean;
|
||||
activeProfileRequiresApiCredentials: boolean;
|
||||
statusMessage: string;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface LastFmAuthStartResult {
|
||||
ok: boolean;
|
||||
authPending: boolean;
|
||||
message: string;
|
||||
authUrl?: string;
|
||||
}
|
||||
|
||||
export interface LastFmAuthFinishResult {
|
||||
ok: boolean;
|
||||
connected: boolean;
|
||||
username: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LastFmCustomProfileInput {
|
||||
protocol?: LastFmScrobbleProtocol;
|
||||
name: string;
|
||||
apiBaseUrl: string;
|
||||
username?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}
|
||||
|
||||
function parseHttpUrl(value: unknown): URL | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLastFmScrobbleProtocol(value: unknown): LastFmScrobbleProtocol {
|
||||
return value === 'audioscrobbler' || value === 'listenbrainz' ? value : 'lastfm2';
|
||||
}
|
||||
|
||||
export function getLastFmProtocolLabel(
|
||||
protocol: LastFmScrobbleProtocol,
|
||||
kind: LastFmProfileKind
|
||||
): string {
|
||||
if (kind === 'official') return 'Official Last.fm';
|
||||
if (protocol === 'audioscrobbler') return 'AudioScrobbler';
|
||||
if (protocol === 'listenbrainz') return 'ListenBrainz';
|
||||
return 'Last.fm 2.0';
|
||||
}
|
||||
|
||||
export function lastFmProfileRequiresApiCredentials(
|
||||
profile: Pick<LastFmProfileConfig, 'kind' | 'protocol'>
|
||||
): boolean {
|
||||
return profile.kind === 'official' && profile.protocol === 'lastfm2';
|
||||
}
|
||||
|
||||
export function parseLastFmApiBaseUrl(value: unknown): string | null {
|
||||
const parsed = parseHttpUrl(value);
|
||||
if (!parsed) return null;
|
||||
|
||||
const normalized = parsed.toString();
|
||||
if (normalized === 'https://ws.audioscrobbler.com/2.0') {
|
||||
return LASTFM_OFFICIAL_API_BASE_URL;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeLastFmApiBaseUrl(value: unknown): string {
|
||||
return parseLastFmApiBaseUrl(value) ?? LASTFM_OFFICIAL_API_BASE_URL;
|
||||
}
|
||||
|
||||
export function isLastFmCustomEndpoint(apiBaseUrl: string): boolean {
|
||||
return normalizeLastFmApiBaseUrl(apiBaseUrl) !== LASTFM_OFFICIAL_API_BASE_URL;
|
||||
}
|
||||
|
||||
export function parseListenBrainzApiBaseUrl(value: unknown): string | null {
|
||||
const parsed = parseHttpUrl(value);
|
||||
if (!parsed) return null;
|
||||
|
||||
const submitSuffix = '/1/submit-listens';
|
||||
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
||||
if (normalizedPath.endsWith(submitSuffix)) {
|
||||
const basePath = normalizedPath.slice(0, -submitSuffix.length);
|
||||
parsed.pathname = basePath.length > 0 ? basePath : '/';
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function normalizeListenBrainzApiBaseUrl(value: unknown): string | null {
|
||||
return parseListenBrainzApiBaseUrl(value);
|
||||
}
|
||||
Reference in New Issue
Block a user