diff --git a/.gitignore b/.gitignore index 2bf3da5..8a7aec7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/package.json b/package.json index caff3b7..6d7790d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 07125e6..482ed55 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -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(null); const [sortSheetOpen, setSortSheetOpen] = useState(false); @@ -60,7 +62,7 @@ export default function LibraryScreen() { {!isEmpty ? ( router.push('/library/search')} + onPress={() => openQuickSearch()} accessibilityRole="button" accessibilityLabel="Search library" > diff --git a/src/app/(tabs)/library/search.tsx b/src/app/(tabs)/library/search.tsx deleted file mode 100644 index a5acde6..0000000 --- a/src/app/(tabs)/library/search.tsx +++ /dev/null @@ -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(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(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 ( - - - router.back()} hitSlop={8} accessibilityRole="button"> - - - - {query.length > 0 ? ( - setQuery('')} hitSlop={8} accessibilityRole="button"> - - - ) : null} - - - {!needle ? ( - - Search your library - - ) : items.length === 0 ? ( - - No results for “{query.trim()}” - - ) : ( - item.key} - getItemType={(item) => item.type} - showsVerticalScrollIndicator={false} - keyboardShouldPersistTaps="handled" - renderItem={({ item }) => { - switch (item.type) { - case 'header': - return ( - - {item.label.toUpperCase()} - - ); - case 'album': - return ( - - router.push({ - pathname: '/library/album/[key]', - params: { key: item.album.identity_key }, - }) - } - /> - ); - case 'artist': - return ( - - router.push({ - pathname: '/library/artist/[name]', - params: { name: item.artist.artist }, - }) - } - /> - ); - case 'track': - return ( - playFrom(item.index)} - onLongPress={() => setActionTrack(item.track)} - /> - ); - } - }} - /> - )} - - setActionTrack(null)} /> - - ); -} - -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, - }, -}); diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index c645432..d261257 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -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() { + + + SCROBBLING + + router.push('/lastfm')} + accessibilityRole="button" + > + + + Last.fm & scrobbling + + {lastFmScrobbleSubtitle(lastFmStatus)} + + + + ); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 009825e..9e4f343 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -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() { + + ); diff --git a/src/app/lastfm/edit.tsx b/src/app/lastfm/edit.tsx new file mode 100644 index 0000000..c732a65 --- /dev/null +++ b/src/app/lastfm/edit.tsx @@ -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 ( + + + {label} + + + + ); +} + +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(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 ( + + + + + + + {backLabel} + + + + + {step === 'type' ? ( + + + Add destination + + + Choose a scrobble service to get started. + + + + {PROTOCOL_OPTIONS.map((option) => ( + chooseProtocol(option.protocol)} + accessibilityRole="button" + > + + + + + {option.label} + + {option.description} + + + + + ))} + + + ) : ( + + + {editing ? 'Edit destination' : `${meta.label} destination`} + + + + + {meta.needsUsername ? ( + + ) : null} + + + {message ? ( + + {message.text} + + ) : null} + + void onSave()} + disabled={!canSubmit || busy} + > + {busy ? ( + + ) : ( + + {editing ? 'Save' : 'Add destination'} + + )} + + + {editing ? ( + + + + Remove destination + + + ) : null} + + )} + + + ); +} + +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, + }, +}); diff --git a/src/app/lastfm/index.tsx b/src/app/lastfm/index.tsx new file mode 100644 index 0000000..53eca28 --- /dev/null +++ b/src/app/lastfm/index.tsx @@ -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 ( + + + + + + + + {profile.name} + + + {profile.protocolLabel} + + + + {subtitle} + + + {profile.connected ? ( + + {profile.username ? ( + + {profile.username} + + ) : null} + confirmDisconnect(profile)} + hitSlop={8} + accessibilityLabel="Disconnect Last.fm" + > + + + + ) : ( + connectOfficial(profile)} + accessibilityRole="button" + > + + + Connect + + + )} + + ); + }; + + const renderCustom = (profile: LastFmProfileStatus) => { + const line = customStatusLine(profile); + return ( + router.push({ pathname: '/lastfm/edit', params: { id: profile.id } })} + accessibilityRole="button" + > + + + + + + + {profile.name} + + + {profile.protocolLabel} + + + + {line.text} + + + {profile.connected ? ( + void setProfileEnabled(profile.id, v)} + trackColor={{ false: colors.glassBorder, true: colors.accent }} + thumbColor={colors.textPrimary} + /> + ) : ( + + )} + + ); + }; + + return ( + + + router.back()} hitSlop={8}> + + + Settings + + + router.push('/lastfm/edit')} + hitSlop={8} + accessibilityLabel="Add destination" + > + + + + + + Scrobbling + + + + + + + Enable scrobbling + + Submit played tracks + "now playing" to your connected destinations. + + + void setEnabled(v)} + trackColor={{ false: colors.glassBorder, true: colors.accent }} + thumbColor={colors.textPrimary} + /> + + + + {status?.statusMessage ? ( + + {status.statusMessage} + + ) : null} + {authHint ? ( + + {authHint} + + ) : null} + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {status && status.pendingScrobbles > 0 ? ( + requestLastFmFlush()}> + + + Retry {status.pendingScrobbles} queued now + + + ) : null} + + + DESTINATIONS + + + + {profiles.map((profile) => + profile.kind === 'official' ? renderOfficial(profile) : renderCustom(profile) + )} + + + router.push('/lastfm/edit')}> + + + Add destination + + + + + 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. + + + + ); +} + +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, + }, +}); diff --git a/src/audio/useLastFmScrobbler.ts b/src/audio/useLastFmScrobbler.ts new file mode 100644 index 0000000..601ba5f --- /dev/null +++ b/src/audio/useLastFmScrobbler.ts @@ -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(); + }, []); +} diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx new file mode 100644 index 0000000..6a5a4bc --- /dev/null +++ b/src/components/search/QuickSearchOverlay.tsx @@ -0,0 +1,1121 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + Keyboard, + Modal, + Pressable, + StyleSheet, + TextInput, + View, + useWindowDimensions, + type GestureResponderEvent, +} from 'react-native'; +import { Image } from 'expo-image'; +import { Ionicons } from '@expo/vector-icons'; +import { FlashList } from '@shopify/flash-list'; +import { useRouter } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Text } from '@/components/Text'; +import { AstraLogo } from '@/components/AstraLogo'; +import { colors, fonts, fontSize, radius, spacing } from '@/theme'; +import { enqueueTop, playTracks } from '@/audio/playbackController'; +import { dbTrackToTrack } from '@/library/trackAdapter'; +import { albumArtworkSource, artworkUri, trackArtworkThumbSource } from '@/library/artwork'; +import { multiFieldScore, MIN_SCORE_THRESHOLD } from '@/lib/fuzzySearch'; +import { formatDuration } from '@/lib/format'; +import { commitHaptic } from '@/lib/haptics'; +import { useLibraryStore } from '@/stores/libraryStore'; +import { usePlaylistStore } from '@/stores/playlistStore'; +import { usePlayerStore } from '@/stores/playerStore'; +import { useSearchStore } from '@/stores/searchStore'; +import type { Album, Artist, DbTrack } from '@/types/library'; +import type { Playlist } from '@/types/playlist'; + +type IconName = keyof typeof Ionicons.glyphMap; +type RouteHref = '/' | '/library' | '/eq' | '/settings' | '/sources' | '/lastfm'; +type LibraryViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders'; + +const NAV_RESULT_LIMIT = 3; +const SETTINGS_RESULT_LIMIT = 4; +const TRACK_RESULT_LIMIT = 5; +const ALBUM_RESULT_LIMIT = 4; +const ARTIST_RESULT_LIMIT = 4; +const PLAYLIST_RESULT_LIMIT = 4; +const EMPTY_RECENT_TRACKS_LIMIT = 3; +const ALL_TRACK_RESULT_LIMIT = 120; +const ALL_ENTITY_RESULT_LIMIT = 80; + +const NAV_ENTRIES: { + id: string; + label: string; + href: RouteHref; + icon: IconName; + keywords: string[]; + libraryViewMode?: LibraryViewMode; +}[] = [ + { + id: 'nav:home', + label: 'Home', + href: '/', + icon: 'home', + keywords: ['home', 'dashboard', 'main'], + }, + { + id: 'nav:library', + label: 'Library', + href: '/library', + icon: 'musical-notes', + keywords: ['library', 'tracks', 'songs', 'browse', 'collection'], + }, + { + id: 'nav:eq', + label: 'Equalizer', + href: '/eq', + icon: 'options', + keywords: ['eq', 'equalizer', 'bands', 'frequency', 'bass', 'treble'], + }, + { + id: 'nav:settings', + label: 'Settings', + href: '/settings', + icon: 'settings', + keywords: ['settings', 'preferences'], + }, +]; + +const SETTING_ENTRIES: { + id: string; + label: string; + subtitle: string; + href: RouteHref; + icon: IconName; + keywords: string[]; + libraryViewMode?: LibraryViewMode; +}[] = [ + { + id: 'setting:audio', + label: 'Audio settings', + subtitle: 'Normalization / ReplayGain', + href: '/settings', + icon: 'volume-high', + keywords: ['normalization', 'replaygain', 'loudness', 'gain', 'target lufs'], + }, + { + id: 'setting:library', + label: 'Artist grouping', + subtitle: 'Astra grouping / file tags', + href: '/settings', + icon: 'people', + keywords: ['library', 'artist grouping', 'collaborators', 'file tags'], + }, + { + id: 'setting:folders', + label: 'Library folders', + subtitle: 'Scanned music folders', + href: '/library', + icon: 'folder-open-outline', + keywords: ['folders', 'scan', 'rescan', 'local files', 'storage'], + libraryViewMode: 'folders', + }, + { + id: 'setting:sources', + label: 'Remote sources', + subtitle: 'Subsonic / Jellyfin servers', + href: '/sources', + icon: 'server-outline', + keywords: ['subsonic', 'jellyfin', 'server', 'streaming', 'remote'], + }, + { + id: 'setting:lastfm', + label: 'Scrobbling', + subtitle: 'Last.fm / ListenBrainz', + href: '/lastfm', + icon: 'radio-outline', + keywords: ['lastfm', 'last.fm', 'listenbrainz', 'scrobble', 'audioscrobbler'], + }, +]; + +const EMPTY_SHORTCUT_IDS = ['nav:library', 'nav:eq', 'setting:sources', 'setting:lastfm']; + +interface ResultGroup { + id: string; + label: string; + results: SearchResult[]; +} + +interface SearchBaseResult { + id: string; + score: number; +} + +interface TrackResult extends SearchBaseResult { + kind: 'track'; + track: DbTrack; +} + +interface AlbumResult extends SearchBaseResult { + kind: 'album'; + album: Album; +} + +interface ArtistResult extends SearchBaseResult { + kind: 'artist'; + artist: Artist; +} + +interface SearchPlaylist { + id: number | 'favorites'; + name: string; + trackCount: number; + coverHash: string | null; + pinned?: boolean; + remote?: boolean; +} + +interface PlaylistResult extends SearchBaseResult { + kind: 'playlist'; + playlist: SearchPlaylist; +} + +interface NavResult extends SearchBaseResult { + kind: 'nav'; + label: string; + subtitle: string; + href: RouteHref; + icon: IconName; + libraryViewMode?: LibraryViewMode; +} + +interface SettingResult extends SearchBaseResult { + kind: 'setting'; + label: string; + subtitle: string; + href: RouteHref; + icon: IconName; + libraryViewMode?: LibraryViewMode; +} + +interface ShowAllResult { + kind: 'show-all'; + id: 'show-all-library'; + query: string; + total: number; +} + +interface ShowTopResult { + kind: 'show-top'; + id: 'show-top-results'; +} + +type SearchResult = + | TrackResult + | AlbumResult + | ArtistResult + | PlaylistResult + | NavResult + | SettingResult + | ShowAllResult + | ShowTopResult; + +type SearchListItem = + | { type: 'header'; key: string; label: string } + | { type: 'result'; key: string; result: SearchResult }; + +function compareScoredResults(a: T, b: T): number { + if (a.score !== b.score) return b.score - a.score; + return a.id.localeCompare(b.id); +} + +function plural(count: number, noun: string): string { + return `${count} ${count === 1 ? noun : `${noun}s`}`; +} + +function scoreOrNull(score: number | null): score is number { + return score !== null && score >= MIN_SCORE_THRESHOLD; +} + +function playlistFromRow(playlist: Playlist): SearchPlaylist { + return { + id: playlist.id, + name: playlist.name, + trackCount: playlist.track_count, + coverHash: playlist.auto_cover_hash, + remote: playlist.remote_source_id != null, + }; +} + +function resultLabel(result: SearchResult): string { + switch (result.kind) { + case 'track': + return result.track.title; + case 'album': + return result.album.album; + case 'artist': + return result.artist.artist; + case 'playlist': + return result.playlist.name; + case 'nav': + case 'setting': + return result.label; + case 'show-all': + return 'Show all library matches'; + case 'show-top': + return 'Back to top matches'; + } +} + +function resultSubtitle(result: SearchResult): string { + switch (result.kind) { + case 'track': + return [result.track.artist, result.track.album, formatDuration(result.track.duration)] + .filter(Boolean) + .join(' / '); + case 'album': + return [`by ${result.album.artist}`, plural(result.album.track_count, 'track')] + .filter(Boolean) + .join(' / '); + case 'artist': + return plural(result.artist.track_count, 'track'); + case 'playlist': + return result.playlist.pinned + ? plural(result.playlist.trackCount, 'favorite') + : plural(result.playlist.trackCount, 'track'); + case 'nav': + case 'setting': + return result.subtitle; + case 'show-all': + return `${plural(result.total, 'match')} for "${result.query}"`; + case 'show-top': + return 'Navigation and settings shortcuts return'; + } +} + +function resultIcon(result: SearchResult): IconName { + switch (result.kind) { + case 'track': + return 'musical-note'; + case 'album': + return 'disc-outline'; + case 'artist': + return 'person'; + case 'playlist': + return result.playlist.pinned ? 'heart' : 'musical-notes-outline'; + case 'nav': + case 'setting': + return result.icon; + case 'show-all': + return 'list'; + case 'show-top': + return 'arrow-up'; + } +} + +function HighlightedLabel({ text, query }: { text: string; query: string }) { + const normalizedText = text.toLocaleLowerCase(); + const normalizedQuery = query.toLocaleLowerCase().trim(); + + if (!normalizedQuery) { + return <>{text}; + } + + const substringIndex = normalizedText.indexOf(normalizedQuery); + if (substringIndex >= 0) { + return ( + <> + {text.slice(0, substringIndex)} + + {text.slice(substringIndex, substringIndex + normalizedQuery.length)} + + {text.slice(substringIndex + normalizedQuery.length)} + + ); + } + + const parts: { text: string; highlighted: boolean; key: string }[] = []; + let queryIndex = 0; + let lastPushed = 0; + + for (let i = 0; i < text.length && queryIndex < normalizedQuery.length; i += 1) { + if (text[i].toLocaleLowerCase() !== normalizedQuery[queryIndex]) continue; + if (i > lastPushed) { + parts.push({ text: text.slice(lastPushed, i), highlighted: false, key: `plain-${i}` }); + } + parts.push({ text: text[i], highlighted: true, key: `mark-${i}` }); + queryIndex += 1; + lastPushed = i + 1; + } + + if (lastPushed < text.length) { + parts.push({ text: text.slice(lastPushed), highlighted: false, key: 'tail' }); + } + + return ( + <> + {parts.map((part) => + part.highlighted ? ( + + {part.text} + + ) : ( + part.text + ) + )} + + ); +} + +function ResultThumb({ result }: { result: SearchResult }) { + const uri = + result.kind === 'track' + ? trackArtworkThumbSource(result.track) + : result.kind === 'album' + ? albumArtworkSource(result.album) + : result.kind === 'artist' && result.artist.artwork_hash + ? artworkUri(result.artist.artwork_hash) + : result.kind === 'playlist' && result.playlist.coverHash + ? artworkUri(result.playlist.coverHash) + : null; + + const icon = resultIcon(result); + const round = result.kind === 'artist'; + const accented = result.kind === 'playlist' && result.playlist.pinned; + + return ( + + {uri ? ( + + ) : result.kind === 'track' ? ( + + ) : ( + + )} + + ); +} + +function ResultRow({ + result, + query, + active, + onPress, + onQueueTrack, +}: { + result: SearchResult; + query: string; + active: boolean; + onPress: () => void; + onQueueTrack: (track: DbTrack) => void; +}) { + const isTrack = result.kind === 'track'; + const isShowMode = result.kind === 'show-all' || result.kind === 'show-top'; + + const queueTrack = (event: GestureResponderEvent) => { + event.stopPropagation(); + if (result.kind !== 'track') return; + onQueueTrack(result.track); + }; + + return ( + + + + + + + + {resultSubtitle(result)} + + + {isTrack ? ( + + + + ) : result.kind === 'playlist' && result.playlist.remote ? ( + + ) : ( + + )} + + ); +} + +function QuickSearchPanel({ + initialQuery, + onClose, +}: { + initialQuery: string; + onClose: () => void; +}) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); + const inputRef = useRef(null); + + const tracks = useLibraryStore((s) => s.tracks); + const albums = useLibraryStore((s) => s.albums); + const artists = useLibraryStore((s) => s.artists); + const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks); + const setViewMode = useLibraryStore((s) => s.setViewMode); + + const playlists = usePlaylistStore((s) => s.playlists); + const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks); + const currentPath = usePlayerStore((s) => s.currentTrack?.path); + + const [query, setQuery] = useState(initialQuery); + const [showAllLibrary, setShowAllLibrary] = useState(false); + const trimmedQuery = query.trim(); + const hasQuery = trimmedQuery.length > 0; + + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 80); + return () => clearTimeout(timer); + }, []); + + const updateQuery = (value: string) => { + setQuery(value); + setShowAllLibrary(false); + }; + + const navResults = useMemo(() => { + if (!hasQuery || showAllLibrary) return [] as NavResult[]; + const results: NavResult[] = []; + for (const entry of NAV_ENTRIES) { + const score = multiFieldScore(trimmedQuery, [ + { value: entry.label, weight: 1.5 }, + { value: entry.keywords.join(' '), weight: 1 }, + ]); + if (!scoreOrNull(score)) continue; + results.push({ + kind: 'nav' as const, + id: entry.id, + score, + label: entry.label, + subtitle: 'Navigate', + href: entry.href, + icon: entry.icon, + libraryViewMode: entry.libraryViewMode, + }); + } + return results.sort(compareScoredResults).slice(0, NAV_RESULT_LIMIT); + }, [hasQuery, showAllLibrary, trimmedQuery]); + + const settingResults = useMemo(() => { + if (!hasQuery || showAllLibrary) return [] as SettingResult[]; + const results: SettingResult[] = []; + for (const entry of SETTING_ENTRIES) { + const score = multiFieldScore(trimmedQuery, [ + { value: entry.label, weight: 1.4 }, + { value: entry.subtitle, weight: 1.1 }, + { value: entry.keywords.join(' '), weight: 1 }, + ]); + if (!scoreOrNull(score)) continue; + results.push({ + kind: 'setting' as const, + id: entry.id, + score, + label: entry.label, + subtitle: entry.subtitle, + href: entry.href, + icon: entry.icon, + libraryViewMode: entry.libraryViewMode, + }); + } + return results.sort(compareScoredResults).slice(0, SETTINGS_RESULT_LIMIT); + }, [hasQuery, showAllLibrary, trimmedQuery]); + + const allTrackResults = useMemo(() => { + if (!hasQuery) return [] as TrackResult[]; + return tracks + .map((track) => { + const score = multiFieldScore(trimmedQuery, [ + { value: track.title, weight: 1.5 }, + { value: track.artist, weight: 1.2 }, + { value: track.album_artist, weight: 1 }, + { value: track.album, weight: 1 }, + { value: track.file_name, weight: 0.8 }, + { value: track.genre, weight: 0.5 }, + ]); + if (!scoreOrNull(score)) return null; + return { + kind: 'track' as const, + id: `track:${track.id}`, + score, + track, + }; + }) + .filter((result): result is TrackResult => result !== null) + .sort(compareScoredResults); + }, [hasQuery, tracks, trimmedQuery]); + + const allAlbumResults = useMemo(() => { + if (!hasQuery) return [] as AlbumResult[]; + return albums + .map((album) => { + const score = multiFieldScore(trimmedQuery, [ + { value: album.album, weight: 1.4 }, + { value: album.artist, weight: 1.1 }, + { value: album.year == null ? null : String(album.year), weight: 0.4 }, + ]); + if (!scoreOrNull(score)) return null; + return { + kind: 'album' as const, + id: `album:${album.identity_key}`, + score, + album, + }; + }) + .filter((result): result is AlbumResult => result !== null) + .sort(compareScoredResults); + }, [albums, hasQuery, trimmedQuery]); + + const allArtistResults = useMemo(() => { + if (!hasQuery) return [] as ArtistResult[]; + return artists + .map((artist) => { + const score = multiFieldScore(trimmedQuery, [{ value: artist.artist, weight: 1.5 }]); + if (!scoreOrNull(score)) return null; + return { + kind: 'artist' as const, + id: `artist:${artist.artist}`, + score, + artist, + }; + }) + .filter((result): result is ArtistResult => result !== null) + .sort(compareScoredResults); + }, [artists, hasQuery, trimmedQuery]); + + const allPlaylistResults = useMemo(() => { + if (!hasQuery) return [] as PlaylistResult[]; + const candidates: SearchPlaylist[] = [ + { + id: 'favorites', + name: 'Favorites', + trackCount: favoriteTracks.length, + coverHash: null, + pinned: true, + }, + ...playlists.map(playlistFromRow), + ]; + return candidates + .map((playlist) => { + const score = multiFieldScore(trimmedQuery, [ + { value: playlist.name, weight: 1.5 }, + ]); + if (!scoreOrNull(score)) return null; + return { + kind: 'playlist' as const, + id: `playlist:${playlist.id}`, + score, + playlist, + }; + }) + .filter((result): result is PlaylistResult => result !== null) + .sort(compareScoredResults); + }, [favoriteTracks.length, hasQuery, playlists, trimmedQuery]); + + const trackResults = showAllLibrary + ? allTrackResults.slice(0, ALL_TRACK_RESULT_LIMIT) + : allTrackResults.slice(0, TRACK_RESULT_LIMIT); + const albumResults = showAllLibrary + ? allAlbumResults.slice(0, ALL_ENTITY_RESULT_LIMIT) + : allAlbumResults.slice(0, ALBUM_RESULT_LIMIT); + const artistResults = showAllLibrary + ? allArtistResults.slice(0, ALL_ENTITY_RESULT_LIMIT) + : allArtistResults.slice(0, ARTIST_RESULT_LIMIT); + const playlistResults = showAllLibrary + ? allPlaylistResults.slice(0, ALL_ENTITY_RESULT_LIMIT) + : allPlaylistResults.slice(0, PLAYLIST_RESULT_LIMIT); + + const recentTrackResults = useMemo(() => { + if (hasQuery) return [] as TrackResult[]; + return recentlyPlayedTracks.slice(0, EMPTY_RECENT_TRACKS_LIMIT).map((track) => ({ + kind: 'track' as const, + id: `recent:${track.id}`, + score: 0, + track, + })); + }, [hasQuery, recentlyPlayedTracks]); + + const quickShortcutResults = useMemo(() => { + if (hasQuery) return [] as (NavResult | SettingResult)[]; + + const results: (NavResult | SettingResult)[] = []; + for (const id of EMPTY_SHORTCUT_IDS) { + const nav = NAV_ENTRIES.find((entry) => entry.id === id); + if (nav) { + results.push({ + kind: 'nav' as const, + id: nav.id, + score: 0, + label: nav.label, + subtitle: 'Navigate', + href: nav.href, + icon: nav.icon, + libraryViewMode: nav.libraryViewMode, + }); + continue; + } + const setting = SETTING_ENTRIES.find((entry) => entry.id === id); + if (!setting) continue; + results.push({ + kind: 'setting' as const, + id: setting.id, + score: 0, + label: setting.label, + subtitle: setting.subtitle, + href: setting.href, + icon: setting.icon, + libraryViewMode: setting.libraryViewMode, + }); + } + return results; + }, [hasQuery]); + + const libraryMatchTotal = + allTrackResults.length + allAlbumResults.length + allArtistResults.length + allPlaylistResults.length; + const visibleLibraryTotal = + trackResults.length + albumResults.length + artistResults.length + playlistResults.length; + const showAllResult = useMemo( + () => + hasQuery && !showAllLibrary && libraryMatchTotal > visibleLibraryTotal + ? { + kind: 'show-all', + id: 'show-all-library', + query: trimmedQuery, + total: libraryMatchTotal, + } + : null, + [hasQuery, libraryMatchTotal, showAllLibrary, trimmedQuery, visibleLibraryTotal] + ); + + const resultGroups = useMemo(() => { + if (!hasQuery) { + const groups: ResultGroup[] = []; + if (recentTrackResults.length > 0) { + groups.push({ id: 'recent', label: 'Recently Played', results: recentTrackResults }); + } + groups.push({ id: 'shortcuts', label: 'Shortcuts', results: quickShortcutResults }); + return groups; + } + + if (showAllLibrary) { + const groups: ResultGroup[] = []; + if (trackResults.length > 0) groups.push({ id: 'tracks', label: 'Tracks', results: trackResults }); + if (albumResults.length > 0) groups.push({ id: 'albums', label: 'Albums', results: albumResults }); + if (artistResults.length > 0) groups.push({ id: 'artists', label: 'Artists', results: artistResults }); + if (playlistResults.length > 0) { + groups.push({ id: 'playlists', label: 'Playlists', results: playlistResults }); + } + return groups; + } + + const pinned: ResultGroup[] = []; + if (navResults.length > 0) { + pinned.push({ id: 'nav', label: 'Go To', results: navResults }); + } + + const scoredGroups: { group: ResultGroup; topScore: number }[] = []; + if (trackResults.length > 0) { + scoredGroups.push({ + group: { id: 'tracks', label: 'Tracks', results: trackResults }, + topScore: trackResults[0].score, + }); + } + if (albumResults.length > 0) { + scoredGroups.push({ + group: { id: 'albums', label: 'Albums', results: albumResults }, + topScore: albumResults[0].score, + }); + } + if (artistResults.length > 0) { + scoredGroups.push({ + group: { id: 'artists', label: 'Artists', results: artistResults }, + topScore: artistResults[0].score, + }); + } + if (playlistResults.length > 0) { + scoredGroups.push({ + group: { id: 'playlists', label: 'Playlists', results: playlistResults }, + topScore: playlistResults[0].score, + }); + } + if (settingResults.length > 0) { + scoredGroups.push({ + group: { id: 'settings', label: 'Settings', results: settingResults }, + topScore: settingResults[0].score, + }); + } + + scoredGroups.sort((a, b) => b.topScore - a.topScore); + return [...pinned, ...scoredGroups.map((entry) => entry.group)]; + }, [ + albumResults, + artistResults, + hasQuery, + navResults, + playlistResults, + quickShortcutResults, + recentTrackResults, + settingResults, + showAllLibrary, + trackResults, + ]); + + const listItems = useMemo(() => { + const items: SearchListItem[] = []; + for (const group of resultGroups) { + items.push({ type: 'header', key: `header:${group.id}`, label: group.label }); + for (const result of group.results) { + items.push({ type: 'result', key: result.id, result }); + } + } + if (showAllResult) { + items.push({ type: 'result', key: showAllResult.id, result: showAllResult }); + } else if (showAllLibrary) { + items.push({ type: 'result', key: 'show-top-results', result: { kind: 'show-top', id: 'show-top-results' } }); + } + return items; + }, [resultGroups, showAllLibrary, showAllResult]); + + const firstActionableResult = listItems.find((item) => item.type === 'result')?.result ?? null; + + const close = () => { + Keyboard.dismiss(); + onClose(); + }; + + const navigateTo = (href: RouteHref) => { + router.push(href); + }; + + const executeResult = (result: SearchResult) => { + if (result.kind === 'show-all') { + setShowAllLibrary(true); + return; + } + if (result.kind === 'show-top') { + setShowAllLibrary(false); + return; + } + + close(); + + if (result.kind === 'track') { + const context = hasQuery ? allTrackResults.map((entry) => entry.track) : recentTrackResults.map((entry) => entry.track); + const index = Math.max( + 0, + context.findIndex((track) => track.path === result.track.path) + ); + void playTracks(context.map(dbTrackToTrack), index); + return; + } + + if (result.kind === 'album') { + router.push({ + pathname: '/library/album/[key]', + params: { key: result.album.identity_key }, + }); + return; + } + + if (result.kind === 'artist') { + router.push({ + pathname: '/library/artist/[name]', + params: { name: result.artist.artist }, + }); + return; + } + + if (result.kind === 'playlist') { + router.push( + result.playlist.id === 'favorites' + ? '/library/playlist/favorites' + : `/library/playlist/${result.playlist.id}` + ); + return; + } + + if (result.libraryViewMode) { + setViewMode(result.libraryViewMode); + } + navigateTo(result.href); + }; + + const queueTrack = (track: DbTrack) => { + commitHaptic(); + void enqueueTop(dbTrackToTrack(track)); + }; + + const submitFirstResult = () => { + if (firstActionableResult) executeResult(firstActionableResult); + }; + + const panelMaxHeight = Math.max(320, height - insets.top - insets.bottom - spacing.xxl * 2); + const emptyText = hasQuery + ? showAllLibrary + ? `No library results for "${trimmedQuery}"` + : `No results for "${trimmedQuery}"` + : 'Type to search tracks, albums, artists, playlists, and settings'; + + return ( + + + + + {query.length > 0 ? ( + updateQuery('')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Clear search" + > + + + ) : null} + + + + + + {showAllLibrary ? ( + + + {`All library matches for "${trimmedQuery}"`} + + + ) : null} + + {listItems.length === 0 ? ( + + + {emptyText} + + + ) : ( + item.key} + getItemType={(item) => item.type} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.resultsContent} + renderItem={({ item }) => + item.type === 'header' ? ( + + {item.label.toUpperCase()} + + ) : ( + executeResult(item.result)} + onQueueTrack={queueTrack} + /> + ) + } + /> + )} + + ); +} + +export function QuickSearchOverlay() { + const isOpen = useSearchStore((s) => s.isQuickSearchOpen); + const initialQuery = useSearchStore((s) => s.initialQuery); + const openVersion = useSearchStore((s) => s.openVersion); + const closeQuickSearch = useSearchStore((s) => s.closeQuickSearch); + + const close = () => { + Keyboard.dismiss(); + closeQuickSearch(); + }; + + return ( + + + + {isOpen ? ( + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + modalRoot: { + flex: 1, + alignItems: 'center', + backgroundColor: 'rgba(2, 4, 8, 0.66)', + paddingHorizontal: spacing.md, + }, + panel: { + width: '100%', + maxWidth: 720, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + overflow: 'hidden', + shadowColor: '#000', + shadowOpacity: 0.45, + shadowRadius: 28, + shadowOffset: { width: 0, height: 18 }, + elevation: 20, + }, + resultsList: { + flex: 1, + }, + inputWrap: { + minHeight: 58, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + borderBottomColor: colors.glassBorder, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + input: { + flex: 1, + minWidth: 0, + fontFamily: fonts.sans.regular, + fontSize: fontSize.base, + color: colors.textPrimary, + paddingVertical: spacing.md, + }, + modeBanner: { + borderBottomColor: colors.glassBorder, + borderBottomWidth: StyleSheet.hairlineWidth, + backgroundColor: colors.glassBg, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + resultsContent: { + paddingHorizontal: spacing.sm, + paddingTop: spacing.sm, + paddingBottom: spacing.md, + }, + groupLabel: { + color: colors.textTertiary, + fontSize: 10, + letterSpacing: 0, + paddingHorizontal: spacing.sm, + paddingTop: spacing.md, + paddingBottom: spacing.xs, + }, + resultRow: { + minHeight: 58, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + borderRadius: radius.md, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + }, + resultRowActive: { + backgroundColor: 'rgba(91, 138, 255, 0.12)', + }, + showModeRow: { + marginTop: spacing.sm, + borderTopColor: colors.glassBorder, + borderTopWidth: StyleSheet.hairlineWidth, + }, + thumb: { + width: 42, + height: 42, + flexShrink: 0, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + thumbRound: { + borderRadius: radius.pill, + }, + thumbImage: { + width: '100%', + height: '100%', + }, + resultText: { + flex: 1, + minWidth: 0, + gap: 2, + }, + resultLabel: { + fontSize: 15, + }, + activeLabel: { + color: colors.accentTextStrong, + }, + resultSubtitle: { + color: colors.textSecondary, + }, + highlight: { + color: colors.accentTextStrong, + backgroundColor: 'rgba(91, 138, 255, 0.22)', + borderRadius: 2, + }, + queueButton: { + width: 32, + height: 32, + flexShrink: 0, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassHighlight, + alignItems: 'center', + justifyContent: 'center', + }, + empty: { + flex: 1, + minHeight: 160, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + }, + emptyText: { + textAlign: 'center', + }, +}); diff --git a/src/lib/fuzzySearch.ts b/src/lib/fuzzySearch.ts new file mode 100644 index 0000000..c4ef1be --- /dev/null +++ b/src/lib/fuzzySearch.ts @@ -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; +} diff --git a/src/services/lastfm/config.ts b/src/services/lastfm/config.ts new file mode 100644 index 0000000..ec8f3da --- /dev/null +++ b/src/services/lastfm/config.ts @@ -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 { + 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 { + 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)); +} diff --git a/src/services/lastfm/constants.ts b/src/services/lastfm/constants.ts new file mode 100644 index 0000000..8d0221a --- /dev/null +++ b/src/services/lastfm/constants.ts @@ -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(); diff --git a/src/services/lastfm/credentials.ts b/src/services/lastfm/credentials.ts new file mode 100644 index 0000000..bfe377c --- /dev/null +++ b/src/services/lastfm/credentials.ts @@ -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 { + return SecureStore.getItemAsync(secretKey(profileId)); +} + +export async function setLastFmSessionKey(profileId: string, sessionKey: string): Promise { + await SecureStore.setItemAsync(secretKey(profileId), sessionKey); +} + +export async function deleteLastFmSessionKey(profileId: string): Promise { + await SecureStore.deleteItemAsync(secretKey(profileId)); +} diff --git a/src/services/lastfm/index.ts b/src/services/lastfm/index.ts new file mode 100644 index 0000000..6d29e48 --- /dev/null +++ b/src/services/lastfm/index.ts @@ -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 | 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 { + 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(); +} diff --git a/src/services/lastfm/scrobbleService.ts b/src/services/lastfm/scrobbleService.ts new file mode 100644 index 0000000..b360c09 Binary files /dev/null and b/src/services/lastfm/scrobbleService.ts differ diff --git a/src/stores/lastFmSettingsStore.ts b/src/stores/lastFmSettingsStore.ts new file mode 100644 index 0000000..e27195e --- /dev/null +++ b/src/stores/lastFmSettingsStore.ts @@ -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; + refresh: () => Promise; + setEnabled: (enabled: boolean) => Promise; + createCustomProfile: (input: LastFmCustomProfileInput) => Promise; + updateCustomProfile: ( + profileId: string, + input: LastFmCustomProfileInput + ) => Promise; + deleteCustomProfile: (profileId: string) => Promise; + setProfileEnabled: (profileId: string, enabled: boolean) => Promise; + beginAuth: (profileId: string) => Promise; + finishAuth: () => Promise; + disconnectProfile: (profileId: string) => Promise; + resetToDefaults: () => Promise; +} + +let statusSubscribed = false; +let authPollTimer: ReturnType | 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((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 => { + const service = await initLastFmService(); + ensureSubscription(); + return applyStatus(service.getStatus()); + }; + + const pollAuthCompletion = async (): Promise => { + 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; + } + }, + }; +}); diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts new file mode 100644 index 0000000..fc54ad5 --- /dev/null +++ b/src/stores/searchStore.ts @@ -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((set) => ({ + isQuickSearchOpen: false, + initialQuery: '', + openVersion: 0, + openQuickSearch: (initialQuery = '') => + set((state) => ({ + isQuickSearchOpen: true, + initialQuery, + openVersion: state.openVersion + 1, + })), + closeQuickSearch: () => set({ isQuickSearchOpen: false }), +})); diff --git a/src/types/lastFm.ts b/src/types/lastFm.ts new file mode 100644 index 0000000..63e52e5 --- /dev/null +++ b/src/types/lastFm.ts @@ -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 +): 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); +}