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