'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useState } from 'react';
import useSWR from 'swr';
import {
SERVICE_LABELS,
SPOTIFY_LINK_PAGES,
STREAMING_SERVICES,
isQuietScrobbler,
type ConnectionDTO,
type MeDTO,
type NowPlayingProvider,
type TrackSearchResultDTO,
} from '@musicshare/shared';
import { SharingControls } from '@/components/SharingControls';
import { SupportCard } from '@/components/SupportLink';
import { api, fetcher, useMe } from '@/lib/api';
import { timeAgo } from '@musicshare/shared';
import { useDebounced } from '@/lib/useDebounced';
export default function SettingsPage() {
return (
);
}
function Settings() {
const { me, loggedOut } = useMe();
const router = useRouter();
if (loggedOut) router.replace('/login');
if (!me) return null;
return (
);
}
function ProfileForm({ me }: { me: MeDTO }) {
const { mutate } = useMe();
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
const str = (k: string) => (f.get(k) as string).trim() || null;
try {
const updated = await api('/me', {
method: 'PATCH',
body: { displayName: str('displayName'), bio: str('bio'), preferredService: str('preferredService') },
});
await mutate(updated, { revalidate: false });
setError(null);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
setError((err as Error).message);
}
};
return (
);
}
function Connections() {
const params = useSearchParams();
const { data: connections = [], mutate } = useSWR('/connections', fetcher);
const errorParam = params.get('error');
const spotifyLimited = errorParam === 'spotify_limited';
const [error, setError] = useState(spotifyLimited ? null : errorParam);
const byProvider = new Map(connections.map((c) => [c.provider, c]));
const run = async (fn: () => Promise) => {
setError(null);
try {
await fn();
await mutate();
} catch (err) {
setError((err as Error).message);
}
};
const connectOAuth = (provider: 'spotify' | 'plex' | 'lastfm') =>
run(async () => {
const { url } = await api<{ url: string }>(`/connections/${provider}/authorize`, { body: { client: 'web' } });
window.location.href = url;
});
const connectJellyfin = (e: React.FormEvent) => {
e.preventDefault();
const body = Object.fromEntries(new FormData(e.currentTarget));
run(() => api('/connections/jellyfin', { body }));
};
const connectUsername = (provider: 'lastfm' | 'listenbrainz') => (e: React.FormEvent) => {
e.preventDefault();
const username = new FormData(e.currentTarget).get('username');
run(() => api(`/connections/${provider}`, { body: { username } }));
};
const disconnect = (provider: NowPlayingProvider) =>
run(() => api(`/connections/${provider}`, { method: 'DELETE' }));
const status = (p: NowPlayingProvider) => {
const c = byProvider.get(p);
if (!c) return null;
return (
{c.status === 'error'
? `Needs reconnecting: ${c.lastError}`
: `Connected as ${c.displayName ?? c.externalUserId}${c.lastPolledAt ? ` · checked ${timeAgo(c.lastPolledAt)}` : ''}`}
);
};
return (
Music services
Connect where you listen. Your friends see what you're playing in real time.
{error && {error}
}
{(['spotify', 'plex'] as const).map((p) => (
{SERVICE_LABELS[p]}
{status(p) ?? (p === 'plex' && Music you play from your Plex servers )}
{byProvider.get(p)?.status === 'active' ? (
disconnect(p)}>
Disconnect
) : (
connectOAuth(p)}>
{byProvider.has(p) ? 'Reconnect' : 'Connect'}
)}
))}
{byProvider.get('jellyfin')?.status === 'active' && (
disconnect('jellyfin')}>
Disconnect
)}
{connections.filter((c) => isQuietScrobbler(c)).map((c) => (
Nothing new from {SERVICE_LABELS[c.provider]} in over a week. If you listen on Spotify, its link to{' '}
{SERVICE_LABELS[c.provider]} expires every 6 months.{' '}
Re-link Spotify
.
))}
{spotifyLimited && (
Spotify sign-in is limited during beta. Spotify only lets a few accounts sign in to new apps
directly. You can still share Spotify through Last.fm: sign in to Last.fm below, then{' '}
connect “Spotify Scrobbling” on Last.fm
.
)}
{(['lastfm', 'listenbrainz'] as const).map((p) => (
{SERVICE_LABELS[p]}
{status(p) ?? (
{p === 'lastfm'
? 'Works with anything that scrobbles: YouTube Music, Tidal, Deezer, SoundCloud…'
: 'Open-source scrobbling'}
)}
{byProvider.has(p) ? (
{p === 'lastfm' && (
Link Spotify
)}
disconnect(p)}>
Disconnect
) : p === 'lastfm' ? (
connectOAuth('lastfm')}>
Sign in with Last.fm
) : (
Connect
)}
))}
Apple Music
{byProvider.has('apple_music') ? status('apple_music') : 'Connect from the musicshare iPhone app (one tap)'}
);
}
const WEBHOOK_HELP: Partial> = {
plex: 'Plex Pass: add this under Plex → Settings → Webhooks for instant updates.',
jellyfin: 'Optional: add this URL in the Jellyfin Webhook plugin (Playback Start/Stop) for instant updates.',
};
function WebhookHint({ connection }: { connection: ConnectionDTO | undefined }) {
const [copied, setCopied] = useState(false);
if (!connection?.webhookUrl || connection.status !== 'active') return null;
const copy = async () => {
await navigator.clipboard.writeText(connection.webhookUrl!);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
Instant updates
{WEBHOOK_HELP[connection.provider]}
{connection.webhookUrl}
{copied ? 'Copied' : 'Copy'}
);
}
function FeaturedPicker() {
const { me } = useMe();
const [q, setQ] = useState('');
const [selected, setSelected] = useState(null);
const [comment, setComment] = useState('');
const [saved, setSaved] = useState(false);
const debounced = useDebounced(q.trim(), 300);
const { data: results } = useSWR(
debounced.length >= 2 && !selected ? `/tracks/search?q=${encodeURIComponent(debounced)}` : null,
fetcher,
);
const save = async () => {
if (!selected) return;
await api('/me/featured', { method: 'PUT', body: { deezerId: selected.deezerId, comment: comment || null } });
setSaved(true);
};
return (
Featured song
Pin a song to your profile, with an optional note about why.
{selected ? (
{selected.artworkUrl &&
}
{selected.title}
{selected.artist}
{
setSelected(null);
setSaved(false);
}}
>
Change
) : (
<>
setQ(e.target.value)} />
{results && (
{results.map((r) => (
setSelected(r)}>
{r.artworkUrl && }
{r.title} · {r.artist}
))}
)}
>
)}
{selected && (
<>
setComment(e.target.value)}
/>
{saved ? 'Featured ✓' : 'Feature this song'}
{saved && me && (
View profile →
)}
>
)}
);
}
function DangerZone() {
const [confirming, setConfirming] = useState(false);
const router = useRouter();
const { mutate } = useMe();
const remove = async () => {
await api('/me', { method: 'DELETE' });
await mutate(undefined, { revalidate: true });
router.push('/');
};
return (
Delete account
Permanently removes your profile, follows and connected services.
{confirming ? (
Yes, delete my account
setConfirming(false)}>
Cancel
) : (
setConfirming(true)}>
Delete account…
)}
);
}