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
+4 -2
View File
@@ -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
+1
View File
@@ -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",
+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,
},
});
+60
View File
@@ -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
+145
View File
@@ -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;
}
+91
View File
@@ -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));
}
+13
View File
@@ -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();
+25
View File
@@ -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));
}
+87
View File
@@ -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.
+335
View File
@@ -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;
}
},
};
});
+22
View File
@@ -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 }),
}));
+172
View File
@@ -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);
}