import { File } from 'expo-file-system';
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, Linking, Platform, StyleSheet, View } from 'react-native';
import useSWR from 'swr';
import {
SERVICE_LABELS,
STREAMING_SERVICES,
timeAgo,
type ConnectionDTO,
type MeDTO,
type NowPlayingProvider,
type StreamingService,
type NotificationType,
NOTIFICATION_PREF_LABELS,
NOTIFICATION_TYPES,
} 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, ListRow, Row, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { WEB_URL } from '@/lib/config';
import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
import { useSession } from '@/lib/session';
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
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;
run(async () => {
// Shrink first: photos from 50-200 MP cameras are huge, and the server keeps 512 px anyway.
const side = Math.min(1024, asset.width || 1024);
const image = await (await ImageManipulator.manipulate(asset.uri).resize({ width: side }).renderAsync()).saveAsync({
compress: 0.85,
format: SaveFormat.JPEG,
});
const form = new FormData();
// Expo's fetch needs a real file object (it rejects React Native's { uri, name, type } parts).
form.append('file', new File(image.uri) as unknown as Blob);
return 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 && }
);
}
/** The services you use: songs offer only these, and open in the main one. */
function MyServices() {
const { me, setMe } = useSession();
const mine = me?.services ?? [];
const save = async (services: StreamingService[], preferredService: StreamingService | null) => {
try {
setMe(await api.updateMe({ services: services.length ? services : null, preferredService }));
} catch (err) {
showError(err);
}
};
const toggle = (s: StreamingService) => {
const services = mine.includes(s) ? mine.filter((x) => x !== s) : [...mine, s];
// The main service has to be one you use; fall back to the first one left.
const main = me?.preferredService && services.includes(me.preferredService) ? me.preferredService : (services[0] ?? null);
save(services, main);
};
return (
Your music services
Songs only show buttons for the ones you pick.
{STREAMING_SERVICES.map((s) => (
toggle(s)} />
))}
{mine.length > 1 && (
<>
Open songs in
{mine.map((s) => (
save(mine, s)} />
))}
>
)}
);
}
/** What to be notified about, and push delivery on this phone. */
function Notifications() {
const { me, setMe } = useSession();
const push = usePushStatus();
const [busy, setBusy] = useState(false);
if (!me) return null;
const toggle = async (type: NotificationType) => {
try {
setMe(await api.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } }));
} catch (err) {
showError(err);
}
};
const turnOn = async () => {
setBusy(true);
try {
const result = await enablePush();
if (result === 'denied') Alert.alert('Notifications are off', 'Allow notifications for musicshare in Android settings, then try again.');
if (result === 'no-distributor') push.refresh();
} catch (err) {
showError(err);
}
setBusy(false);
};
const turnOff = async () => {
setBusy(true);
await disablePush().catch((err) => showError(err));
push.refresh();
setBusy(false);
};
return (
Notifications
Notify me about:
{NOTIFICATION_TYPES.map((t) => (
toggle(t)} />
))}
{pushAvailable &&
(push.enabled ? (
<>
On for this phone{push.distributor ? `, delivered by ${push.distributor}` : ''}.
>
) : push.hasDistributor ? (
) : (
<>
musicshare doesn't use Google to deliver notifications. Install ntfy (free and open source), then come back and turn
them on. You'll still see everything under the bell on the feed without it.
>
))}
);
}
/** 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.
)}
router.push('/share-setup')}
style={{ alignSelf: 'flex-start' }}
/>
);
}
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 linkSpotifyOnListenBrainz = () => openSpotifyLinkPage('listenbrainz').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') ? (
disconnect('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') ? (
) : (
openSignupPage('lastfm')} style={{ alignSelf: 'flex-start' }} />
)}
run(() => api.connectUsername('listenbrainz', username))}
onDisconnect={() => disconnect('listenbrainz')}
onLinkSpotify={linkSpotifyOnListenBrainz}
/>
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,
onLinkSpotify,
}: {
provider: 'lastfm' | 'listenbrainz';
status: React.ReactNode;
active: boolean;
onConnect: (username: string) => Promise;
onDisconnect: () => void;
onLinkSpotify: () => void;
}) {
const [username, setUsername] = useState('');
const [busy, setBusy] = useState(false);
return (
{status}
{active && }
{!active && (
{
setBusy(true);
await onConnect(username.trim());
setBusy(false);
}}
/>
)}
{active ? (
) : (
<>
openSignupPage(provider)} style={{ alignSelf: 'flex-start' }} />
{provider === 'listenbrainz' && (
ListenBrainz uses MusicBrainz accounts: create one there, then sign in to ListenBrainz once.
)}
>
)}
);
}
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);
}}
/>
)}
);
}
/** This build's version, and checking for a newer one (our own APKs; F-Droid updates its builds itself). */
function AppVersion() {
const state = useUpdateState();
if (!updaterAvailable) return null;
const status = (() => {
switch (state.status) {
case 'checking':
return 'Checking for updates…';
case 'upToDate':
return 'Up to date.';
case 'downloading':
return `Downloading ${state.update.version}… ${Math.round(state.progress * 100)}%`;
case 'ready':
return `Version ${state.update.version} is ready to install.`;
case 'installing':
return `Installing ${state.update.version}…`;
case 'error':
return state.message;
default:
return null;
}
})();
return (
App version {currentVersion()}
{status && (
{status}
)}
{state.status === 'ready' || state.status === 'installing' ? (
installUpdate(true)}
style={{ alignSelf: 'flex-start' }}
/>
) : (
checkForUpdate({ force: true })}
style={{ alignSelf: 'flex-start' }}
/>
)}
);
}
function Account() {
const { me, setMe, signOut } = useSession();
const resend = async () => {
try {
await api.resendVerification();
Alert.alert('Sent', `We sent a new link to ${me?.email}.`);
} catch (err) {
showError(err);
}
};
const cancelChange = async () => {
try {
setMe(await api.cancelEmailChange());
} catch (err) {
showError(err);
}
};
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);
}
},
},
]);
const email = () =>
Alert.alert(
me?.email ?? 'Email',
me?.pendingEmail
? `Changing to ${me.pendingEmail}: open the link we sent there.`
: me?.emailVerified
? 'Confirmed.'
: 'Not confirmed yet. Open the link we emailed you.',
[
{ text: 'Change email', onPress: () => router.push('/change-email') },
...(me?.pendingEmail
? [{ text: 'Cancel the change', onPress: cancelChange }]
: !me?.emailVerified
? [{ text: 'Send the link again', onPress: resend }]
: []),
{ text: 'Close', style: 'cancel' as const },
],
);
const confirmSignOut = () =>
Alert.alert('Log out?', undefined, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Log out', onPress: signOut },
]);
return (
Account
router.push('/change-password')} />
router.push('/blocked')} />
Linking.openURL(`${WEB_URL}/privacy`)} />
Linking.openURL(`${WEB_URL}/terms`)} />
Signed in as @{me?.username}
);
}
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 },
});