Mobile: redesign, onboarding, QR codes, logo, Spotify setup
- Redesign matching the web: album-art backdrops, Reanimated equalizer, spinning live ring, drifting ambient background, gradient buttons with spring press feedback and haptics, Space Grotesk headings, animated feed (enter + layout transitions); fix invisible tab labels - Four-step onboarding for new accounts: which app you listen with (sets where songs open), the matching share setup, find friends (search, scan, invite), and a featured song. Skippable; stored server-side - QR codes: My code (share link) and an in-app scanner (camera only, no microphone permission) - Spotify setup via Last.fm or ListenBrainz with guided linking; Android shares Spotify automatically through the phone reader - New logo as app icon, adaptive/monochrome icon and splash - app.config.ts allows cleartext only for local http:// test builds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
import { RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import useSWR from 'swr';
|
||||
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||
import { SharingControls } from '@/components/SharingControls';
|
||||
@@ -71,7 +73,10 @@ export default function FeedScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||
<FlatList
|
||||
<AmbientBackground />
|
||||
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
|
||||
<Animated.FlatList
|
||||
itemLayoutAnimation={LinearTransition.springify().damping(18)}
|
||||
data={feed ?? []}
|
||||
keyExtractor={(item) => item.user.id}
|
||||
contentContainerStyle={styles.list}
|
||||
@@ -115,13 +120,15 @@ export default function FeedScreen() {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<NowPlayingCard
|
||||
np={item}
|
||||
preferred={me?.preferredService}
|
||||
pinned={Boolean(item.pinnedAt)}
|
||||
onTogglePin={() => togglePin(item)}
|
||||
/>
|
||||
renderItem={({ item, index }) => (
|
||||
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
|
||||
<NowPlayingCard
|
||||
np={item}
|
||||
preferred={me?.preferredService}
|
||||
pinned={Boolean(item.pinnedAt)}
|
||||
onTogglePin={() => togglePin(item)}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import useSWR from 'swr';
|
||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Body, Field, Title } from '@/components/ui';
|
||||
import { Body, Button, Field, Row, Title } from '@/components/ui';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
@@ -16,6 +17,7 @@ export default function SearchScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||
<AmbientBackground />
|
||||
<FlatList
|
||||
data={query.length >= 2 ? (data ?? []) : []}
|
||||
keyExtractor={(u) => u.id}
|
||||
@@ -24,6 +26,10 @@ export default function SearchScreen() {
|
||||
ListHeaderComponent={
|
||||
<View style={{ gap: space.md }}>
|
||||
<Title>Find friends</Title>
|
||||
<Row>
|
||||
<Button small variant="primary" title="Scan a code" onPress={() => router.push('/scan')} />
|
||||
<Button small title="My code" onPress={() => router.push('/qr')} />
|
||||
</Row>
|
||||
<Field
|
||||
placeholder="Search by name or username"
|
||||
value={q}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Alert, Platform, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
SERVICE_LABELS,
|
||||
@@ -11,17 +8,17 @@ import {
|
||||
timeAgo,
|
||||
type ConnectionDTO,
|
||||
type NowPlayingProvider,
|
||||
type TrackSearchResultDTO,
|
||||
} from '@musicshare/shared';
|
||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||
import { FeaturedPicker } from '@/components/FeaturedPicker';
|
||||
import { Screen } from '@/components/Screen';
|
||||
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 { useDebounced } from '@/lib/useDebounced';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
@@ -130,53 +127,19 @@ function Connections() {
|
||||
await mutate();
|
||||
};
|
||||
|
||||
/** Sign in on the service's site and come back via deep link. Returns true when connected. */
|
||||
const signIn = async (provider: 'spotify' | 'plex' | 'lastfm'): Promise<boolean> => {
|
||||
// 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') return false;
|
||||
const error = Linking.parse(result.url).queryParams?.error;
|
||||
if (error === 'spotify_limited') {
|
||||
offerSpotifyViaLastfm();
|
||||
return false;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const connectOAuth = (provider: 'spotify' | 'plex' | 'lastfm') => run(() => signIn(provider));
|
||||
|
||||
/**
|
||||
* Spotify only lets small apps sign in a handful of accounts. Spotify's own
|
||||
* Last.fm link works for everyone: sign in to Last.fm here, then approve
|
||||
* "Spotify Scrobbling" on Last.fm.
|
||||
*/
|
||||
const linkSpotifyOnLastfm = () =>
|
||||
WebBrowser.openBrowserAsync('https://www.last.fm/settings/applications').catch((e) => showError(e));
|
||||
|
||||
const setUpSpotifyViaLastfm = () =>
|
||||
/** Sign in on the service's site (Plex, Last.fm) and come back to the app. */
|
||||
const connectOAuth = (provider: 'plex' | 'lastfm') =>
|
||||
run(async () => {
|
||||
if (!byProvider.has('lastfm') && !(await signIn('lastfm'))) return;
|
||||
Alert.alert('Last step: link Spotify', 'On the next page, tap Connect next to "Spotify Scrobbling" and approve.', [
|
||||
{ text: 'Open Last.fm', onPress: linkSpotifyOnLastfm },
|
||||
]);
|
||||
try {
|
||||
await signInWithService(provider);
|
||||
} catch (err) {
|
||||
if (err instanceof SignInError) Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, err.message);
|
||||
else throw err;
|
||||
}
|
||||
});
|
||||
|
||||
function offerSpotifyViaLastfm() {
|
||||
Alert.alert(
|
||||
'Spotify sign-in is limited during beta',
|
||||
"Spotify only allows a few accounts to sign in to new apps directly. You can still share Spotify through Last.fm: two quick approvals, and it works for everyone.",
|
||||
[
|
||||
{ text: 'Not now', style: 'cancel' },
|
||||
{ text: 'Set up', onPress: setUpSpotifyViaLastfm },
|
||||
],
|
||||
);
|
||||
}
|
||||
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.', [
|
||||
@@ -229,25 +192,39 @@ function Connections() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(['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)} />
|
||||
)}
|
||||
{/* Spotify caps new apps at a handful of accounts, so Spotify comes in through
|
||||
its own Last.fm / ListenBrainz link (or the Android toggle). */}
|
||||
<View style={[styles.connectionColumn, styles.connection]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider}>Spotify</Text>
|
||||
<Body muted style={styles.small}>
|
||||
{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.'}
|
||||
</Body>
|
||||
</View>
|
||||
))}
|
||||
<Button small variant={spotifyLinked ? 'secondary' : 'primary'} title={spotifyLinked ? 'Check link' : 'Set up'} onPress={() => router.push('/spotify-setup')} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.connectionColumn, styles.connection]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider}>Plex</Text>
|
||||
{status('plex', 'Music you play from your Plex servers')}
|
||||
</View>
|
||||
{isActive('plex') ? (
|
||||
<Button small title="Disconnect" onPress={() => disconnect('plex')} />
|
||||
) : (
|
||||
<Button small variant="primary" title={byProvider.has('plex') ? 'Reconnect' : 'Connect'} onPress={() => connectOAuth('plex')} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.connectionColumn}>
|
||||
<View style={styles.connection}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider}>Last.fm</Text>
|
||||
{status('lastfm', 'Also the way to share Spotify for everyone (via Spotify’s Last.fm link).')}
|
||||
{status('lastfm', 'Scrobbles from Spotify, TIDAL, YouTube Music and more.')}
|
||||
</View>
|
||||
{byProvider.has('lastfm') ? (
|
||||
<Button small title="Disconnect" onPress={() => disconnect('lastfm')} />
|
||||
@@ -387,82 +364,6 @@ function JellyfinConnection({
|
||||
);
|
||||
}
|
||||
|
||||
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 = () =>
|
||||
|
||||
Reference in New Issue
Block a user