Add Expo mobile app for iOS and Android
Native tabs for feed, friend search, profile and settings, with feature parity with the web app. Sessions are stored in SecureStore, realtime disconnects in the background, and Spotify/Plex sign-in returns to the app via the URL scheme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
SERVICE_LABELS,
|
||||
STREAMING_SERVICES,
|
||||
timeAgo,
|
||||
type ConnectionDTO,
|
||||
type NowPlayingProvider,
|
||||
type TrackSearchResultDTO,
|
||||
} from '@musicshare/shared';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
return (
|
||||
<Screen>
|
||||
<Title>Settings</Title>
|
||||
<ProfileSection />
|
||||
<PreferredService />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
<Account />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSection() {
|
||||
const { me, setMe } = useSession();
|
||||
const [displayName, setDisplayName] = useState(me?.displayName ?? '');
|
||||
const [bio, setBio] = useState(me?.bio ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setMe(await api.updateMe({ displayName: displayName.trim() || null, bio: bio.trim() || null }));
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Profile</Heading>
|
||||
<Field label="Display name" value={displayName} onChangeText={setDisplayName} maxLength={64} />
|
||||
<Field label="Bio" value={bio} onChangeText={setBio} maxLength={280} multiline style={{ minHeight: 64 }} />
|
||||
<Button title="Save" variant="primary" loading={busy} onPress={save} style={{ alignSelf: 'flex-start' }} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PreferredService() {
|
||||
const { me, setMe } = useSession();
|
||||
const choose = async (service: (typeof STREAMING_SERVICES)[number]) => {
|
||||
try {
|
||||
setMe(await api.updateMe({ preferredService: service }));
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Open songs in</Heading>
|
||||
<Row>
|
||||
{STREAMING_SERVICES.map((s) => (
|
||||
<Chip key={s} label={SERVICE_LABELS[s]} selected={me?.preferredService === s} onPress={() => choose(s)} />
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Connections() {
|
||||
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections');
|
||||
const byProvider = new Map(connections.map((c) => [c.provider, c]));
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
await mutate();
|
||||
};
|
||||
|
||||
const connectOAuth = (provider: 'spotify' | 'plex') =>
|
||||
run(async () => {
|
||||
// Deep link back into the app; in Expo Go this is an exp:// URL, in builds musicshare://
|
||||
const returnTo = Linking.createURL('settings');
|
||||
const { url } = await api.authorizeUrl(provider, { client: 'mobile', returnTo });
|
||||
const result = await WebBrowser.openAuthSessionAsync(url, returnTo);
|
||||
if (result.type === 'success') {
|
||||
const error = Linking.parse(result.url).queryParams?.error;
|
||||
if (typeof error === 'string') Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
|
||||
}
|
||||
});
|
||||
|
||||
const disconnect = (provider: NowPlayingProvider) =>
|
||||
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Disconnect', style: 'destructive', onPress: () => run(() => api.disconnect(provider)) },
|
||||
]);
|
||||
|
||||
const status = (p: NowPlayingProvider, fallback: string) => {
|
||||
const c = byProvider.get(p);
|
||||
if (!c) return <Body muted style={styles.small}>{fallback}</Body>;
|
||||
if (c.status === 'error') return <Body style={[styles.small, { color: colors.danger }]}>Needs reconnecting: {c.lastError}</Body>;
|
||||
return (
|
||||
<Body muted style={styles.small}>
|
||||
Connected as {c.displayName ?? c.externalUserId}
|
||||
{c.lastPolledAt ? ` · checked ${timeAgo(c.lastPolledAt)}` : ''}
|
||||
</Body>
|
||||
);
|
||||
};
|
||||
|
||||
const isActive = (p: NowPlayingProvider) => byProvider.get(p)?.status === 'active';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Music services</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
Connect where you listen. Friends see what you're playing in real time.
|
||||
</Body>
|
||||
|
||||
{(['spotify', 'plex'] as const).map((p) => (
|
||||
<View key={p} style={[styles.connectionColumn, styles.connection]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider}>{SERVICE_LABELS[p]}</Text>
|
||||
{status(p, p === 'plex' ? 'Music you play from your Plex servers' : 'Live from your Spotify account')}
|
||||
</View>
|
||||
{isActive(p) ? (
|
||||
<Button small title="Disconnect" onPress={() => disconnect(p)} />
|
||||
) : (
|
||||
<Button small variant="primary" title={byProvider.has(p) ? 'Reconnect' : 'Connect'} onPress={() => connectOAuth(p)} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{(['lastfm', 'listenbrainz'] as const).map((p) => (
|
||||
<UsernameConnection
|
||||
key={p}
|
||||
provider={p}
|
||||
status={status(p, p === 'lastfm' ? 'Anything that scrobbles: YouTube Music, Tidal, Deezer…' : 'Open-source scrobbling')}
|
||||
active={byProvider.has(p)}
|
||||
onConnect={(username) => run(() => api.connectUsername(p, username))}
|
||||
onDisconnect={() => disconnect(p)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<JellyfinConnection
|
||||
status={status('jellyfin', 'Sign in to your Jellyfin server. Your password isn’t stored.')}
|
||||
active={isActive('jellyfin')}
|
||||
onConnect={(input) => run(() => api.connectJellyfin(input))}
|
||||
onDisconnect={() => disconnect('jellyfin')}
|
||||
/>
|
||||
|
||||
{connections.some((c) => c.webhookUrl) && (
|
||||
<Body muted style={styles.small}>
|
||||
Instant updates for Plex/Jellyfin can be set up from Settings on the web.
|
||||
</Body>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UsernameConnection({
|
||||
provider,
|
||||
status,
|
||||
active,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}: {
|
||||
provider: 'lastfm' | 'listenbrainz';
|
||||
status: React.ReactNode;
|
||||
active: boolean;
|
||||
onConnect: (username: string) => Promise<void>;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<View style={styles.connectionColumn}>
|
||||
<View style={styles.connection}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider}>{SERVICE_LABELS[provider]}</Text>
|
||||
{status}
|
||||
</View>
|
||||
{active && <Button small title="Disconnect" onPress={onDisconnect} />}
|
||||
</View>
|
||||
{!active && (
|
||||
<Row style={{ flexWrap: 'nowrap' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Field placeholder="Username" value={username} onChangeText={setUsername} autoCapitalize="none" autoCorrect={false} />
|
||||
</View>
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title="Connect"
|
||||
loading={busy}
|
||||
disabled={!username.trim()}
|
||||
onPress={async () => {
|
||||
setBusy(true);
|
||||
await onConnect(username.trim());
|
||||
setBusy(false);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function JellyfinConnection({
|
||||
status,
|
||||
active,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}: {
|
||||
status: React.ReactNode;
|
||||
active: boolean;
|
||||
onConnect: (input: { serverUrl: string; username: string; password: string }) => Promise<void>;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<View style={styles.connectionColumn}>
|
||||
<View style={styles.connection}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider}>Jellyfin</Text>
|
||||
{status}
|
||||
</View>
|
||||
{active && <Button small title="Disconnect" onPress={onDisconnect} />}
|
||||
</View>
|
||||
{!active && (
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Field
|
||||
placeholder="https://jellyfin.example.com"
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
<Field placeholder="Username" value={username} onChangeText={setUsername} autoCapitalize="none" autoCorrect={false} />
|
||||
<Field placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title="Connect"
|
||||
loading={busy}
|
||||
disabled={!serverUrl.trim() || !username.trim()}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
onPress={async () => {
|
||||
setBusy(true);
|
||||
await onConnect({ serverUrl: serverUrl.trim(), username: username.trim(), password });
|
||||
setPassword('');
|
||||
setBusy(false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedPicker() {
|
||||
const [q, setQ] = useState('');
|
||||
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(null);
|
||||
const [comment, setComment] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const query = useDebounced(q.trim(), 300);
|
||||
const { data: results } = useSWR<TrackSearchResultDTO[]>(
|
||||
query.length >= 2 && !selected ? `/tracks/search?q=${encodeURIComponent(query)}` : null,
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setFeatured({ deezerId: selected.deezerId, comment: comment.trim() || null });
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Featured song</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
Pin a song to your profile, with an optional note about why.
|
||||
</Body>
|
||||
{selected ? (
|
||||
<>
|
||||
<View style={styles.result}>
|
||||
{selected.artworkUrl && <Image source={selected.artworkUrl} style={styles.thumb} />}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider} numberOfLines={1}>
|
||||
{selected.title}
|
||||
</Text>
|
||||
<Body muted style={styles.small}>
|
||||
{selected.artist}
|
||||
</Body>
|
||||
</View>
|
||||
<Button small title="Change" onPress={() => (setSelected(null), setSaved(false))} />
|
||||
</View>
|
||||
<Field
|
||||
placeholder="Say something about it (optional)"
|
||||
value={comment}
|
||||
onChangeText={setComment}
|
||||
maxLength={280}
|
||||
multiline
|
||||
style={{ minHeight: 64 }}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
title={saved ? 'Featured ✓' : 'Feature this song'}
|
||||
loading={busy}
|
||||
onPress={save}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field placeholder="Search for a song" value={q} onChangeText={setQ} autoCorrect={false} returnKeyType="search" />
|
||||
{results?.map((r) => (
|
||||
<Pressable key={r.deezerId} style={styles.result} onPress={() => setSelected(r)} accessibilityRole="button">
|
||||
{r.artworkUrl && <Image source={r.artworkUrl} style={styles.thumb} />}
|
||||
<Text style={styles.resultText} numberOfLines={1}>
|
||||
{r.title} <Text style={{ color: colors.muted }}>· {r.artist}</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Account() {
|
||||
const { me, signOut } = useSession();
|
||||
const confirmDelete = () =>
|
||||
Alert.alert('Delete your account?', 'This permanently removes your profile, follows and connected services.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await api.deleteAccount();
|
||||
await signOut();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Account</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'}
|
||||
</Body>
|
||||
<Row>
|
||||
<Button small title="Log out" onPress={signOut} />
|
||||
<Button small variant="danger" title="Delete account" onPress={confirmDelete} />
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
small: { fontSize: 13 },
|
||||
provider: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
connection: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||
connectionColumn: {
|
||||
gap: space.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.border,
|
||||
paddingTop: space.md,
|
||||
},
|
||||
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
||||
resultText: { color: colors.text, flex: 1, fontSize: 14 },
|
||||
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
||||
});
|
||||
Reference in New Issue
Block a user