import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, Platform, StyleSheet, View } from 'react-native';
import useSWR from 'swr';
import {
SERVICE_LABELS,
STREAMING_SERVICES,
timeAgo,
type ConnectionDTO,
type MeDTO,
type NowPlayingProvider,
} from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { RelinkReminder } from '@/components/RelinkReminder';
import { FeaturedPicker } from '@/components/FeaturedPicker';
import { Screen } from '@/components/Screen';
import { ServiceName } from '@/components/ServiceName';
import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
export default function SettingsScreen() {
return (
Settings
);
}
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 (
Profile
);
}
function AvatarPicker() {
const { me, setMe } = useSession();
const [busy, setBusy] = useState(false);
if (!me) return null;
const run = async (fn: () => Promise) => {
setBusy(true);
try {
setMe(await fn());
} catch (err) {
showError(err, "Couldn't update your picture");
}
setBusy(false);
};
const pick = async () => {
// The system photo picker needs no permission on Android 13+; iOS asks the first time.
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.85,
});
const asset = result.canceled ? null : result.assets[0];
if (!asset) return;
const form = new FormData();
// React Native's FormData takes a file as { uri, name, type }.
form.append('file', { uri: asset.uri, name: asset.fileName ?? 'avatar.jpg', type: asset.mimeType ?? 'image/jpeg' } as unknown as Blob);
run(() => api.uploadAvatar(form));
};
const remove = () =>
Alert.alert('Remove your picture?', undefined, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => run(() => api.removeAvatar()) },
]);
return (
{me.avatarUrl && !busy && }
);
}
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 (
Open songs in
{STREAMING_SERVICES.map((s) => (
choose(s)} />
))}
);
}
/** Android: share from any music app on this phone (no-op elsewhere). */
function ThisPhone() {
const { available, granted, playback } = useReader();
if (!available) return null;
return (
This phone
{granted ? (
{playback?.title
? `Sharing ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''} from ${playback.appName ?? 'a music app'}.`
: 'On. Anything you play in a music app is shared.'}
) : (
Share what you play in Spotify, YouTube Music, Apple Music and other music apps. No accounts needed.
)}
);
}
function Connections() {
const { data: connections = [], mutate } = useSWR('/connections');
const byProvider = new Map(connections.map((c) => [c.provider, c]));
const run = async (fn: () => Promise) => {
try {
await fn();
} catch (err) {
showError(err);
}
await mutate();
};
/** Sign in on the service's site (Plex, Last.fm) and come back to the app. */
const connectOAuth = (provider: 'plex' | 'lastfm') =>
run(async () => {
try {
await signInWithService(provider);
} catch (err) {
if (err instanceof SignInError) Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, err.message);
else throw err;
}
});
const linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e));
const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz');
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 {fallback};
if (c.status === 'error') return Needs reconnecting: {c.lastError};
return (
Connected as {c.displayName ?? c.externalUserId}
{c.lastPolledAt ? ` · checked ${timeAgo(c.lastPolledAt)}` : ''}
);
};
const isActive = (p: NowPlayingProvider) => byProvider.get(p)?.status === 'active';
const linkAppleMusic = () =>
run(async () => {
try {
await connectAppleMusic();
} catch (err) {
if (err instanceof AppleMusicPermissionError) Alert.alert('Apple Music access needed', err.message);
else throw err;
}
});
return (
Music services
Connect where you listen. Friends see what you're playing in real time.
{appleMusicAvailable && (
{status('apple_music', 'One tap. Live while the app is open, recently played otherwise.')}
{isActive('apple_music') ? (
)}
{/* Spotify caps new apps at a handful of accounts, so Spotify comes in through
its own Last.fm / ListenBrainz link (or the Android toggle). */}
{Platform.OS === 'android'
? 'Shared automatically by “This phone” above. On other devices, connect it through Last.fm or ListenBrainz.'
: spotifyLinked
? 'Through Spotify’s link to Last.fm or ListenBrainz. Make sure it’s linked there.'
: 'Connect it through Last.fm or ListenBrainz: Spotify sends your plays there, and we share them.'}
router.push('/spotify-setup')} />
{status('plex', 'Music you play from your Plex servers')}
{isActive('plex') ? (
disconnect('plex')} />
) : (
connectOAuth('plex')} />
)}
{status('lastfm', 'Scrobbles from Spotify, TIDAL, YouTube Music and more.')}
{byProvider.has('lastfm') ? (
disconnect('lastfm')} />
) : (
connectOAuth('lastfm')} />
)}
{byProvider.has('lastfm') && (
)}
run(() => api.connectUsername('listenbrainz', username))}
onDisconnect={() => disconnect('listenbrainz')}
/>
run(() => api.connectJellyfin(input))}
onDisconnect={() => disconnect('jellyfin')}
/>
{connections.some((c) => c.webhookUrl) && (
Instant updates for Plex/Jellyfin can be set up from Settings on the web.
)}
);
}
function UsernameConnection({
provider,
status,
active,
onConnect,
onDisconnect,
}: {
provider: 'lastfm' | 'listenbrainz';
status: React.ReactNode;
active: boolean;
onConnect: (username: string) => Promise;
onDisconnect: () => void;
}) {
const [username, setUsername] = useState('');
const [busy, setBusy] = useState(false);
return (
{status}
{active && }
{!active && (
{
setBusy(true);
await onConnect(username.trim());
setBusy(false);
}}
/>
)}
);
}
function JellyfinConnection({
status,
active,
onConnect,
onDisconnect,
}: {
status: React.ReactNode;
active: boolean;
onConnect: (input: { serverUrl: string; username: string; password: string }) => Promise;
onDisconnect: () => void;
}) {
const [serverUrl, setServerUrl] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
return (
{status}
{active && }
{!active && (
{
setBusy(true);
await onConnect({ serverUrl: serverUrl.trim(), username: username.trim(), password });
setPassword('');
setBusy(false);
}}
/>
)}
);
}
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 (
Account
Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'}
);
}
const styles = StyleSheet.create({
small: { fontSize: 13 },
avatarRow: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
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 },
});