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
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app.json holds the real config; this only adds build-time switches.
|
||||||
|
*
|
||||||
|
* Local test builds point at a dev server over plain http://, which Android
|
||||||
|
* blocks by default in release builds. Allow cleartext only when the API URL
|
||||||
|
* itself is http://, so store builds (https) stay locked down.
|
||||||
|
*/
|
||||||
|
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||||
|
const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? '';
|
||||||
|
const localHttp = apiUrl.startsWith('http://');
|
||||||
|
return {
|
||||||
|
...(config as ExpoConfig),
|
||||||
|
plugins: [
|
||||||
|
...(config.plugins ?? []),
|
||||||
|
['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"scheme": "musicshare",
|
"scheme": "musicshare",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"ios": {
|
"ios": {
|
||||||
"icon": "./assets/expo.icon",
|
"icon": "./assets/images/icon.png",
|
||||||
"bundleIdentifier": "tech.chrisedwards.musicshare",
|
"bundleIdentifier": "tech.chrisedwards.musicshare",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSAppleMusicUsageDescription": "musicshare shows your friends what you play in Apple Music, and opens their songs for you."
|
"NSAppleMusicUsageDescription": "musicshare shows your friends what you play in Apple Music, and opens their songs for you."
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#0e0f13",
|
"backgroundColor": "#1a0a4a",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
@@ -30,9 +30,17 @@
|
|||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"backgroundColor": "#0e0f13",
|
"backgroundColor": "#0b0820",
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"imageWidth": 76
|
"imageWidth": 150
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "musicshare uses the camera to scan friends' QR codes.",
|
||||||
|
"microphonePermission": false,
|
||||||
|
"recordAudioAndroid": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 780 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 61 KiB |
@@ -3,14 +3,19 @@
|
|||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||||
"@expo/ui": "~57.0.21",
|
"@expo/ui": "~57.0.21",
|
||||||
"@musicshare/shared": "*",
|
"@musicshare/shared": "*",
|
||||||
"expo": "~57.0.26",
|
"expo": "~57.0.26",
|
||||||
|
"expo-build-properties": "~57.0.22",
|
||||||
|
"expo-camera": "~57.0.6",
|
||||||
"expo-constants": "~57.0.20",
|
"expo-constants": "~57.0.20",
|
||||||
"expo-device": "~57.0.2",
|
"expo-device": "~57.0.2",
|
||||||
"expo-font": "~57.0.4",
|
"expo-font": "~57.0.4",
|
||||||
"expo-glass-effect": "~57.0.4",
|
"expo-glass-effect": "~57.0.4",
|
||||||
|
"expo-haptics": "~57.0.3",
|
||||||
"expo-image": "~57.0.5",
|
"expo-image": "~57.0.5",
|
||||||
|
"expo-linear-gradient": "~57.0.2",
|
||||||
"expo-linking": "~57.0.11",
|
"expo-linking": "~57.0.11",
|
||||||
"expo-router": "~57.0.24",
|
"expo-router": "~57.0.24",
|
||||||
"expo-secure-store": "~57.0.4",
|
"expo-secure-store": "~57.0.4",
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "~4.26.0",
|
"react-native-screens": "~4.26.0",
|
||||||
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.10.1",
|
"react-native-worklets": "0.10.1",
|
||||||
"swr": "^2.5.1"
|
"swr": "^2.5.1"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useEffect, useState } from 'react';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||||||
|
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||||
import { SharingControls } from '@/components/SharingControls';
|
import { SharingControls } from '@/components/SharingControls';
|
||||||
@@ -71,7 +73,10 @@ export default function FeedScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
<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 ?? []}
|
data={feed ?? []}
|
||||||
keyExtractor={(item) => item.user.id}
|
keyExtractor={(item) => item.user.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
@@ -115,13 +120,15 @@ export default function FeedScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item, index }) => (
|
||||||
<NowPlayingCard
|
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
|
||||||
np={item}
|
<NowPlayingCard
|
||||||
preferred={me?.preferredService}
|
np={item}
|
||||||
pinned={Boolean(item.pinnedAt)}
|
preferred={me?.preferredService}
|
||||||
onTogglePin={() => togglePin(item)}
|
pinned={Boolean(item.pinnedAt)}
|
||||||
/>
|
onTogglePin={() => togglePin(item)}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||||
|
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||||
import { Avatar } from '@/components/Avatar';
|
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 { useDebounced } from '@/lib/useDebounced';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, space } from '@/lib/theme';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export default function SearchScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||||
|
<AmbientBackground />
|
||||||
<FlatList
|
<FlatList
|
||||||
data={query.length >= 2 ? (data ?? []) : []}
|
data={query.length >= 2 ? (data ?? []) : []}
|
||||||
keyExtractor={(u) => u.id}
|
keyExtractor={(u) => u.id}
|
||||||
@@ -24,6 +26,10 @@ export default function SearchScreen() {
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={{ gap: space.md }}>
|
<View style={{ gap: space.md }}>
|
||||||
<Title>Find friends</Title>
|
<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
|
<Field
|
||||||
placeholder="Search by name or username"
|
placeholder="Search by name or username"
|
||||||
value={q}
|
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 { router } from 'expo-router';
|
||||||
import { useState } from 'react';
|
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 useSWR from 'swr';
|
||||||
import {
|
import {
|
||||||
SERVICE_LABELS,
|
SERVICE_LABELS,
|
||||||
@@ -11,17 +8,17 @@ import {
|
|||||||
timeAgo,
|
timeAgo,
|
||||||
type ConnectionDTO,
|
type ConnectionDTO,
|
||||||
type NowPlayingProvider,
|
type NowPlayingProvider,
|
||||||
type TrackSearchResultDTO,
|
|
||||||
} from '@musicshare/shared';
|
} from '@musicshare/shared';
|
||||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||||
|
import { FeaturedPicker } from '@/components/FeaturedPicker';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { SharingControls } from '@/components/SharingControls';
|
import { SharingControls } from '@/components/SharingControls';
|
||||||
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
|
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
|
||||||
|
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||||
import { useDebounced } from '@/lib/useDebounced';
|
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
@@ -130,53 +127,19 @@ function Connections() {
|
|||||||
await mutate();
|
await mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sign in on the service's site and come back via deep link. Returns true when connected. */
|
/** Sign in on the service's site (Plex, Last.fm) and come back to the app. */
|
||||||
const signIn = async (provider: 'spotify' | 'plex' | 'lastfm'): Promise<boolean> => {
|
const connectOAuth = (provider: 'plex' | 'lastfm') =>
|
||||||
// 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 = () =>
|
|
||||||
run(async () => {
|
run(async () => {
|
||||||
if (!byProvider.has('lastfm') && !(await signIn('lastfm'))) return;
|
try {
|
||||||
Alert.alert('Last step: link Spotify', 'On the next page, tap Connect next to "Spotify Scrobbling" and approve.', [
|
await signInWithService(provider);
|
||||||
{ text: 'Open Last.fm', onPress: linkSpotifyOnLastfm },
|
} catch (err) {
|
||||||
]);
|
if (err instanceof SignInError) Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, err.message);
|
||||||
|
else throw err;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function offerSpotifyViaLastfm() {
|
const linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e));
|
||||||
Alert.alert(
|
const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz');
|
||||||
'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 disconnect = (provider: NowPlayingProvider) =>
|
const disconnect = (provider: NowPlayingProvider) =>
|
||||||
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
|
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
|
||||||
@@ -229,25 +192,39 @@ function Connections() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(['spotify', 'plex'] as const).map((p) => (
|
{/* Spotify caps new apps at a handful of accounts, so Spotify comes in through
|
||||||
<View key={p} style={[styles.connectionColumn, styles.connection]}>
|
its own Last.fm / ListenBrainz link (or the Android toggle). */}
|
||||||
<View style={{ flex: 1 }}>
|
<View style={[styles.connectionColumn, styles.connection]}>
|
||||||
<Text style={styles.provider}>{SERVICE_LABELS[p]}</Text>
|
<View style={{ flex: 1 }}>
|
||||||
{status(p, p === 'plex' ? 'Music you play from your Plex servers' : 'Live from your Spotify account')}
|
<Text style={styles.provider}>Spotify</Text>
|
||||||
</View>
|
<Body muted style={styles.small}>
|
||||||
{isActive(p) ? (
|
{Platform.OS === 'android'
|
||||||
<Button small title="Disconnect" onPress={() => disconnect(p)} />
|
? 'Shared automatically by “This phone” above. On other devices, connect it through Last.fm or ListenBrainz.'
|
||||||
) : (
|
: spotifyLinked
|
||||||
<Button small variant="primary" title={byProvider.has(p) ? 'Reconnect' : 'Connect'} onPress={() => connectOAuth(p)} />
|
? '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>
|
</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.connectionColumn}>
|
||||||
<View style={styles.connection}>
|
<View style={styles.connection}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.provider}>Last.fm</Text>
|
<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>
|
</View>
|
||||||
{byProvider.has('lastfm') ? (
|
{byProvider.has('lastfm') ? (
|
||||||
<Button small title="Disconnect" onPress={() => disconnect('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() {
|
function Account() {
|
||||||
const { me, signOut } = useSession();
|
const { me, signOut } = useSession();
|
||||||
const confirmDelete = () =>
|
const confirmDelete = () =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SpaceGrotesk_500Medium, SpaceGrotesk_700Bold, useFonts } from '@expo-google-fonts/space-grotesk';
|
||||||
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
|
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
@@ -33,17 +34,20 @@ export default function RootLayout() {
|
|||||||
function AppNavigator() {
|
function AppNavigator() {
|
||||||
const { status, me, retry } = useSession();
|
const { status, me, retry } = useSession();
|
||||||
const signedIn = status === 'signedIn';
|
const signedIn = status === 'signedIn';
|
||||||
|
const onboarded = Boolean(me?.onboardedAt);
|
||||||
|
const [fontsLoaded, fontError] = useFonts({ SpaceGrotesk_500Medium, SpaceGrotesk_700Bold });
|
||||||
|
const ready = status !== 'loading' && (fontsLoaded || Boolean(fontError));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'loading') SplashScreen.hideAsync();
|
if (ready) SplashScreen.hideAsync();
|
||||||
}, [status]);
|
}, [ready]);
|
||||||
|
|
||||||
// Keep the Android listener pointed at this account (no-op elsewhere).
|
// Keep the Android listener pointed at this account (no-op elsewhere).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (signedIn) syncDevice().catch(() => {});
|
if (signedIn) syncDevice().catch(() => {});
|
||||||
}, [signedIn]);
|
}, [signedIn]);
|
||||||
|
|
||||||
if (status === 'loading') return null;
|
if (!ready) return null;
|
||||||
if (status === 'offline') {
|
if (status === 'offline') {
|
||||||
return (
|
return (
|
||||||
<View style={styles.offline}>
|
<View style={styles.offline}>
|
||||||
@@ -75,10 +79,20 @@ function AppNavigator() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
contentStyle: { backgroundColor: colors.bg },
|
contentStyle: { backgroundColor: colors.bg },
|
||||||
}}>
|
}}>
|
||||||
<Stack.Protected guard={signedIn}>
|
<Stack.Protected guard={signedIn && onboarded}>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="u/[username]" options={{ title: '' }} />
|
|
||||||
<Stack.Screen name="share-setup" options={{ headerShown: false, presentation: 'modal' }} />
|
<Stack.Screen name="share-setup" options={{ headerShown: false, presentation: 'modal' }} />
|
||||||
|
<Stack.Screen name="qr" options={{ title: 'My code', presentation: 'modal' }} />
|
||||||
|
<Stack.Screen name="spotify-setup" options={{ headerShown: false, presentation: 'modal' }} />
|
||||||
|
</Stack.Protected>
|
||||||
|
{/* New accounts go through onboarding first (finishing flips onboardedAt and lands on the feed). */}
|
||||||
|
<Stack.Protected guard={signedIn && !onboarded}>
|
||||||
|
<Stack.Screen name="onboarding" options={{ headerShown: false, gestureEnabled: false }} />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={signedIn}>
|
||||||
|
{/* Reachable from onboarding too (scanning a friend's code, viewing a profile). */}
|
||||||
|
<Stack.Screen name="u/[username]" options={{ title: '' }} />
|
||||||
|
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Protected guard={!signedIn}>
|
<Stack.Protected guard={!signedIn}>
|
||||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
export default function OnboardingLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg }, animation: 'slide_from_right' }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Share, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { profileUrl, type UserSummaryDTO } from '@musicshare/shared';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { OnboardingStep } from '@/components/OnboardingStep';
|
||||||
|
import { Body, Button, Field, Row, Title } from '@/components/ui';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { WEB_URL } from '@/lib/config';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { successHaptic } from '@/lib/haptics';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { colors, space } from '@/lib/theme';
|
||||||
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
|
|
||||||
|
/** Step 3: an empty feed is the fastest way to lose someone, so find friends now. */
|
||||||
|
export default function FriendsStep() {
|
||||||
|
const { me } = useSession();
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [followed, setFollowed] = useState<Set<string>>(new Set());
|
||||||
|
const query = useDebounced(q.trim(), 250);
|
||||||
|
const { data: results } = useSWR<UserSummaryDTO[]>(query.length >= 2 ? `/users/search?q=${encodeURIComponent(query)}` : null);
|
||||||
|
|
||||||
|
const follow = async (username: string) => {
|
||||||
|
try {
|
||||||
|
await api.follow(username);
|
||||||
|
successHaptic();
|
||||||
|
setFollowed((s) => new Set(s).add(username));
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const invite = () => {
|
||||||
|
if (!me) return;
|
||||||
|
const url = profileUrl(WEB_URL, me.username);
|
||||||
|
Share.share({ message: `I'm on musicshare: follow me and see what I'm listening to → ${url}`, url }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingStep step={3}>
|
||||||
|
<View style={{ gap: space.sm }}>
|
||||||
|
<Title>Find your friends</Title>
|
||||||
|
<Body muted>Follow people to see what they’re playing, live.</Body>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Button small variant="primary" title="Scan a code" onPress={() => router.push('/scan')} />
|
||||||
|
<Button small title="Invite friends" onPress={invite} />
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<View style={{ gap: space.sm }}>
|
||||||
|
<Field placeholder="Search by name or username" value={q} onChangeText={setQ} autoCapitalize="none" autoCorrect={false} />
|
||||||
|
{results
|
||||||
|
?.filter((u) => u.id !== me?.id)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((u) => (
|
||||||
|
<View key={u.id} style={styles.row}>
|
||||||
|
<Pressable style={styles.who} onPress={() => router.push(`/u/${u.username}`)}>
|
||||||
|
<Avatar user={u} size={36} />
|
||||||
|
<View style={{ flexShrink: 1 }}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{u.displayName ?? u.username}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.handle}>@{u.username}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<Button
|
||||||
|
small
|
||||||
|
variant={followed.has(u.username) ? 'secondary' : 'primary'}
|
||||||
|
title={followed.has(u.username) ? 'Following' : 'Follow'}
|
||||||
|
disabled={followed.has(u.username)}
|
||||||
|
onPress={() => follow(u.username)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{query.length >= 2 && results?.length === 0 && <Body muted>No one by that name yet. Invite them!</Body>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={followed.size ? 'primary' : 'secondary'}
|
||||||
|
title={followed.size ? `Continue (${followed.size} followed)` : 'Skip for now'}
|
||||||
|
onPress={() => router.push('/onboarding/song')}
|
||||||
|
/>
|
||||||
|
</OnboardingStep>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.md },
|
||||||
|
who: { flexDirection: 'row', alignItems: 'center', gap: space.md, flexShrink: 1 },
|
||||||
|
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||||
|
handle: { color: colors.muted, fontSize: 13 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { OnboardingStep } from '@/components/OnboardingStep';
|
||||||
|
import { Body, Button, Title } from '@/components/ui';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { selectionHaptic } from '@/lib/haptics';
|
||||||
|
import { LISTEN_APPS, finishOnboarding } from '@/lib/onboarding';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** Step 1: where do you listen? Sets where friends' songs open, and picks the right setup next. */
|
||||||
|
export default function ListenAppStep() {
|
||||||
|
const { me, setMe } = useSession();
|
||||||
|
const [picked, setPicked] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const next = async () => {
|
||||||
|
const app = LISTEN_APPS.find((a) => a.id === picked);
|
||||||
|
if (!app) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (app.service) setMe(await api.updateMe({ preferredService: app.service }));
|
||||||
|
router.push({ pathname: '/onboarding/share', params: { app: app.id } });
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingStep step={1}>
|
||||||
|
<View style={{ gap: space.sm }}>
|
||||||
|
<Title>Hi {me?.displayName ?? me?.username} 👋</Title>
|
||||||
|
<Body muted>Which app do you mostly listen with?</Body>
|
||||||
|
</View>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{LISTEN_APPS.map((app, i) => (
|
||||||
|
<Animated.View key={app.id} entering={FadeInDown.delay(80 + i * 35).springify().damping(16)} style={styles.cell}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ checked: picked === app.id }}
|
||||||
|
onPress={() => (selectionHaptic(), setPicked(app.id))}
|
||||||
|
style={[styles.option, picked === app.id && styles.optionPicked]}>
|
||||||
|
<Text style={[styles.optionText, picked === app.id && styles.optionTextPicked]}>{app.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={{ gap: space.sm }}>
|
||||||
|
<Button variant="primary" title="Continue" disabled={!picked} loading={busy} onPress={next} />
|
||||||
|
<Button title="Skip setup" onPress={() => finishOnboarding(setMe).catch(showError)} />
|
||||||
|
</View>
|
||||||
|
</OnboardingStep>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: space.sm },
|
||||||
|
cell: { width: '48.5%' },
|
||||||
|
option: {
|
||||||
|
minHeight: 56,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: space.md,
|
||||||
|
},
|
||||||
|
optionPicked: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.18)' },
|
||||||
|
optionText: { color: colors.text, fontWeight: '600', fontSize: 15 },
|
||||||
|
optionTextPicked: { color: '#fff' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { OnboardingStep } from '@/components/OnboardingStep';
|
||||||
|
import { AndroidShareSetup } from '@/components/setup/AndroidShareSetup';
|
||||||
|
import { AppleMusicSetup } from '@/components/setup/AppleMusicSetup';
|
||||||
|
import { ServiceSignInSetup } from '@/components/setup/ServiceSignInSetup';
|
||||||
|
import { SpotifySetup } from '@/components/setup/SpotifySetup';
|
||||||
|
import { Points, SetupActions, SetupHeader } from '@/components/setup/common';
|
||||||
|
import { listenApp } from '@/lib/onboarding';
|
||||||
|
|
||||||
|
const next = () => router.push('/onboarding/friends');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: share what you play, matched to the app and the phone.
|
||||||
|
* Android: one toggle covers every app. iPhone: depends on the service.
|
||||||
|
*/
|
||||||
|
export default function ShareStep() {
|
||||||
|
const { app: appId } = useLocalSearchParams<{ app?: string }>();
|
||||||
|
const app = listenApp(appId);
|
||||||
|
const props = { onDone: next, skipLabel: 'Skip for now' };
|
||||||
|
|
||||||
|
let body: React.ReactNode;
|
||||||
|
if (Platform.OS === 'android') body = <AndroidShareSetup {...props} />;
|
||||||
|
else if (app.id === 'apple_music') body = <AppleMusicSetup {...props} />;
|
||||||
|
else if (app.id === 'spotify') body = <SpotifySetup {...props} />;
|
||||||
|
else if (app.id === 'plex') body = <ServiceSignInSetup service="plex" appName="Plex" {...props} />;
|
||||||
|
else if (app.id === 'jellyfin') {
|
||||||
|
body = (
|
||||||
|
<>
|
||||||
|
<SetupHeader
|
||||||
|
title="Connect Jellyfin"
|
||||||
|
subtitle="Jellyfin needs your server address and login, so set it up in Settings → Music services whenever you're ready."
|
||||||
|
/>
|
||||||
|
<Points items={['Your password is only used once to get a token; it isn’t stored.']} />
|
||||||
|
<SetupActions primary="Continue" onPrimary={next} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else body = <ServiceSignInSetup service="lastfm" appName={app.label} {...props} />;
|
||||||
|
|
||||||
|
return <OnboardingStep step={2}>{body}</OnboardingStep>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { FeaturedPicker } from '@/components/FeaturedPicker';
|
||||||
|
import { OnboardingStep } from '@/components/OnboardingStep';
|
||||||
|
import { Body, Button, Title } from '@/components/ui';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { successHaptic } from '@/lib/haptics';
|
||||||
|
import { finishOnboarding } from '@/lib/onboarding';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { space } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** Step 4 (optional): a song on your profile, so it isn't empty for the friends you invite. */
|
||||||
|
export default function SongStep() {
|
||||||
|
const { setMe } = useSession();
|
||||||
|
const [featured, setFeatured] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Finishing flips the navigator to the main app automatically.
|
||||||
|
const finish = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await finishOnboarding(setMe);
|
||||||
|
successHaptic();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingStep step={4}>
|
||||||
|
<View style={{ gap: space.sm }}>
|
||||||
|
<Title>Pick your song</Title>
|
||||||
|
<Body muted>One song that says something about you. It goes at the top of your profile, with a note if you like.</Body>
|
||||||
|
</View>
|
||||||
|
<FeaturedPicker bare onSaved={() => setFeatured(true)} />
|
||||||
|
<Button variant={featured ? 'primary' : 'secondary'} title={featured ? 'Finish' : 'Skip and finish'} loading={busy} onPress={finish} />
|
||||||
|
</OnboardingStep>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Share, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { profileUrl } from '@musicshare/shared';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { QrCode } from '@/components/QrCode';
|
||||||
|
import { Screen } from '@/components/Screen';
|
||||||
|
import { Body, Button } from '@/components/ui';
|
||||||
|
import { WEB_URL } from '@/lib/config';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { colors, space } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** "My code": friends scan it (in musicshare or any camera app) to open your profile. */
|
||||||
|
export default function MyQrScreen() {
|
||||||
|
const { me } = useSession();
|
||||||
|
if (!me) return null;
|
||||||
|
const url = profileUrl(WEB_URL, me.username);
|
||||||
|
|
||||||
|
const share = () =>
|
||||||
|
Share.share({ message: `Follow me on musicshare and see what I'm listening to: ${url}`, url }).catch(() => {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen edges={['bottom']} contentContainerStyle={styles.content}>
|
||||||
|
<View style={styles.who}>
|
||||||
|
<Avatar user={me} size={56} />
|
||||||
|
<Text style={styles.name}>{me.displayName ?? me.username}</Text>
|
||||||
|
<Text style={styles.handle}>@{me.username}</Text>
|
||||||
|
</View>
|
||||||
|
<QrCode value={url} size={260} />
|
||||||
|
<Body muted style={styles.hint}>
|
||||||
|
Friends scan this with musicshare (or their camera app) to open your profile.
|
||||||
|
</Body>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button variant="primary" title="Scan a friend’s code" onPress={() => router.replace('/scan')} />
|
||||||
|
<Button title="Share link" onPress={share} />
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
content: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', gap: space.xl },
|
||||||
|
who: { alignItems: 'center', gap: space.xs },
|
||||||
|
name: { color: colors.text, fontSize: 22, fontWeight: '800' },
|
||||||
|
handle: { color: colors.muted, fontSize: 15 },
|
||||||
|
hint: { textAlign: 'center', maxWidth: 300 },
|
||||||
|
actions: { alignSelf: 'stretch', gap: space.sm },
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Linking, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { parseProfileLink } from '@musicshare/shared';
|
||||||
|
import { Screen } from '@/components/Screen';
|
||||||
|
import { Body, Button, Title } from '@/components/ui';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** Scan a friend's musicshare QR code and open their profile (with its Follow button). */
|
||||||
|
export default function ScanScreen() {
|
||||||
|
const { me } = useSession();
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
// The camera reports the same code many times a second; act on the first.
|
||||||
|
const handled = useRef(false);
|
||||||
|
|
||||||
|
const onScanned = ({ data }: BarcodeScanningResult) => {
|
||||||
|
if (handled.current) return;
|
||||||
|
const username = parseProfileLink(data);
|
||||||
|
if (!username) {
|
||||||
|
setMessage('That’s not a musicshare code.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (username === me?.username) {
|
||||||
|
setMessage('That’s your own code. Show it to a friend instead!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handled.current = true;
|
||||||
|
router.replace(`/u/${username}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission) return <Screen />;
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<Screen edges={['bottom']} contentContainerStyle={styles.centered}>
|
||||||
|
<Title>Scan a friend’s code</Title>
|
||||||
|
<Body muted style={{ textAlign: 'center' }}>
|
||||||
|
musicshare needs the camera to scan QR codes. It’s only used while this screen is open.
|
||||||
|
</Body>
|
||||||
|
{permission.canAskAgain ? (
|
||||||
|
<Button variant="primary" title="Allow camera" onPress={requestPermission} />
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" title="Open Settings" onPress={() => Linking.openSettings()} />
|
||||||
|
)}
|
||||||
|
<Button title="Show my code instead" onPress={() => router.replace('/qr')} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.fill}>
|
||||||
|
<CameraView style={styles.fill} facing="back" barcodeScannerSettings={{ barcodeTypes: ['qr'] }} onBarcodeScanned={onScanned} />
|
||||||
|
<View style={styles.overlay} pointerEvents="box-none">
|
||||||
|
<View style={styles.frame} />
|
||||||
|
<Text style={styles.prompt}>{message ?? 'Point at a friend’s musicshare code'}</Text>
|
||||||
|
<Button title="Show my code" onPress={() => router.replace('/qr')} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
fill: { flex: 1, backgroundColor: '#000' },
|
||||||
|
centered: { flexGrow: 1, justifyContent: 'center', gap: space.lg },
|
||||||
|
overlay: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center', gap: space.xl, padding: space.xl },
|
||||||
|
frame: { width: 240, height: 240, borderRadius: radius.lg, borderWidth: 3, borderColor: colors.accentText },
|
||||||
|
prompt: {
|
||||||
|
color: colors.accentText,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||||
|
paddingHorizontal: space.md,
|
||||||
|
paddingVertical: space.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,132 +1,26 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Platform, StyleSheet, View } from 'react-native';
|
import { Platform, StyleSheet } from 'react-native';
|
||||||
import { mutate } from 'swr';
|
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Body, Button, Card, Title } from '@/components/ui';
|
import { AndroidShareSetup } from '@/components/setup/AndroidShareSetup';
|
||||||
import { showError } from '@/lib/errors';
|
import { AppleMusicSetup } from '@/components/setup/AppleMusicSetup';
|
||||||
import { AppleMusicPermissionError, connectAppleMusic } from '@/lib/appleMusic';
|
|
||||||
import { openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
|
|
||||||
import { markSetupSeen } from '@/lib/setup';
|
import { markSetupSeen } from '@/lib/setup';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { space } from '@/lib/theme';
|
||||||
|
|
||||||
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
|
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
|
||||||
|
|
||||||
|
/** Standalone "share what you're playing" (from the feed card), outside onboarding. */
|
||||||
export default function ShareSetupScreen() {
|
export default function ShareSetupScreen() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
markSetupSeen();
|
markSetupSeen();
|
||||||
}, []);
|
}, []);
|
||||||
return Platform.OS === 'ios' ? <AppleMusicSetup /> : <AndroidSetup />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** iPhone: Apple Music is one system prompt. Other services live in Settings. */
|
|
||||||
function AppleMusicSetup() {
|
|
||||||
const [connecting, setConnecting] = useState(false);
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const connect = async () => {
|
|
||||||
setConnecting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await connectAppleMusic();
|
|
||||||
await mutate('/connections');
|
|
||||||
setConnected(true);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof AppleMusicPermissionError ? err.message : "Couldn't connect Apple Music. Try again.");
|
|
||||||
}
|
|
||||||
setConnecting(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
||||||
<View style={{ gap: space.md }}>
|
{Platform.OS === 'ios' ? <AppleMusicSetup onDone={close} /> : <AndroidShareSetup onDone={close} />}
|
||||||
<Title>{connected ? "You're all set" : 'Share what you’re playing'}</Title>
|
|
||||||
<Body muted>
|
|
||||||
{connected
|
|
||||||
? 'Play something in Apple Music and your friends will see it.'
|
|
||||||
: 'Connect Apple Music and your friends see what you play. It takes one tap.'}
|
|
||||||
</Body>
|
|
||||||
</View>
|
|
||||||
{!connected && (
|
|
||||||
<Card>
|
|
||||||
<Body style={styles.point}>• We see the songs you play in Apple Music, and nothing else.</Body>
|
|
||||||
<Body style={styles.point}>• Use Spotify or Plex? Connect them in Settings.</Body>
|
|
||||||
<Body style={styles.point}>• Pause sharing or disconnect any time.</Body>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
|
|
||||||
<View style={{ gap: space.sm }}>
|
|
||||||
{connected ? (
|
|
||||||
<Button variant="primary" title="Done" onPress={close} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button variant="primary" title="Connect Apple Music" loading={connecting} onPress={connect} />
|
|
||||||
<Button title="Not now" onPress={close} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android: one toggle covers every music app. This screen doubles as the
|
|
||||||
* prominent disclosure Google Play requires before the notification-access screen.
|
|
||||||
*/
|
|
||||||
function AndroidSetup() {
|
|
||||||
const { granted, playback } = useReader();
|
|
||||||
const [synced, setSynced] = useState(false);
|
|
||||||
const finishing = granted && !synced;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!granted) return;
|
|
||||||
syncDevice()
|
|
||||||
.catch((err) => showError(err, "Couldn't finish setup"))
|
|
||||||
.finally(() => setSynced(true));
|
|
||||||
}, [granted]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
|
||||||
<View style={{ gap: space.md }}>
|
|
||||||
<Title>{granted ? "You're all set" : 'Share what you’re playing'}</Title>
|
|
||||||
{granted ? (
|
|
||||||
<Body muted>
|
|
||||||
{playback?.title
|
|
||||||
? `Friends can see you're listening to ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''}.`
|
|
||||||
: 'Play something in Spotify, YouTube Music, Apple Music or any music app and your friends will see it.'}
|
|
||||||
</Body>
|
|
||||||
) : (
|
|
||||||
<Body muted>
|
|
||||||
Works with Spotify, YouTube Music, Apple Music, TIDAL, Deezer, SoundCloud and more. No accounts to link.
|
|
||||||
</Body>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{!granted && (
|
|
||||||
<Card>
|
|
||||||
<Body style={styles.point}>• We read the song, artist and album from music apps, and nothing else.</Body>
|
|
||||||
<Body style={styles.point}>• We never see your notifications, messages or other apps.</Body>
|
|
||||||
<Body style={styles.point}>• Android will call this “notification access”. That’s the only way it lets apps see what’s playing.</Body>
|
|
||||||
<Body style={styles.point}>• Turn it off any time in Settings.</Body>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={{ gap: space.sm }}>
|
|
||||||
{granted ? (
|
|
||||||
<Button variant="primary" title="Done" loading={finishing} onPress={close} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button variant="primary" title="Turn on" onPress={openReaderSettings} />
|
|
||||||
<Button title="Not now" onPress={close} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
content: { flexGrow: 1, justifyContent: 'center', gap: space.xl },
|
content: { flexGrow: 1, justifyContent: 'center', gap: space.xl },
|
||||||
point: { color: colors.text, fontSize: 14 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import { Screen } from '@/components/Screen';
|
||||||
|
import { SpotifySetup } from '@/components/setup/SpotifySetup';
|
||||||
|
import { space } from '@/lib/theme';
|
||||||
|
|
||||||
|
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
|
||||||
|
|
||||||
|
/** Spotify via Last.fm or ListenBrainz, opened from Settings. */
|
||||||
|
export default function SpotifySetupScreen() {
|
||||||
|
return (
|
||||||
|
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
||||||
|
<SpotifySetup onDone={close} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
content: { flexGrow: 1, justifyContent: 'center', gap: space.lg },
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||||
|
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||||
|
|
||||||
|
/** Two soft colour blobs drifting slowly behind the content, so screens never feel static. */
|
||||||
|
export function AmbientBackground() {
|
||||||
|
const { width, height } = useWindowDimensions();
|
||||||
|
const t = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
t.set(withRepeat(withTiming(1, { duration: 18000, easing: Easing.inOut(Easing.sin) }), -1, true));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const size = Math.max(width, height) * 0.9;
|
||||||
|
const purple = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: -size * 0.35 + t.value * width * 0.35 }, { translateY: -size * 0.3 + t.value * height * 0.12 }],
|
||||||
|
}));
|
||||||
|
const blue = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: width - size * 0.6 - t.value * width * 0.3 }, { translateY: height - size * 0.55 - t.value * height * 0.1 }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.bg }]} pointerEvents="none">
|
||||||
|
<AnimatedImage source={require('@/assets/images/ambient-purple.png')} style={[styles.blob, { width: size, height: size }, purple]} />
|
||||||
|
<AnimatedImage source={require('@/assets/images/ambient-blue.png')} style={[styles.blob, { width: size, height: size }, blue]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
blob: { position: 'absolute', top: 0, left: 0, opacity: 0.45 },
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Image } from 'expo-image';
|
||||||
import { Link } from 'expo-router';
|
import { Link } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native';
|
import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native';
|
||||||
@@ -32,7 +33,8 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
|||||||
<Screen edges={mode === 'login' ? ['top'] : []} contentContainerStyle={styles.content}>
|
<Screen edges={mode === 'login' ? ['top'] : []} contentContainerStyle={styles.content}>
|
||||||
{mode === 'login' && (
|
{mode === 'login' && (
|
||||||
<View style={{ gap: space.sm }}>
|
<View style={{ gap: space.sm }}>
|
||||||
<Title style={{ fontSize: 34 }}>musicshare</Title>
|
<Image source={require('@/assets/images/icon.png')} style={styles.logo} />
|
||||||
|
<Title style={{ fontSize: 36 }}>musicshare</Title>
|
||||||
<Body muted>See what your friends are playing, and open it in your own app.</Body>
|
<Body muted>See what your friends are playing, and open it in your own app.</Body>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -85,4 +87,5 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
content: { flexGrow: 1, justifyContent: 'center' },
|
content: { flexGrow: 1, justifyContent: 'center' },
|
||||||
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
|
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
|
||||||
|
logo: { width: 76, height: 76, borderRadius: 20, marginBottom: space.sm },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,48 @@
|
|||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Animated, { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
||||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||||
import { colors } from '@/lib/theme';
|
import { colors, fonts } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** `live` wraps the avatar in a spinning gradient ring (someone is listening right now). */
|
||||||
|
export function Avatar({ user, size = 40, live }: { user: UserSummaryDTO; size?: number; live?: boolean }) {
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (live) rotation.set(withRepeat(withTiming(360, { duration: 3500, easing: Easing.linear }), -1, false));
|
||||||
|
else cancelAnimation(rotation);
|
||||||
|
return () => cancelAnimation(rotation);
|
||||||
|
}, [live, rotation]);
|
||||||
|
const spin = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }] }));
|
||||||
|
|
||||||
export function Avatar({ user, size = 40 }: { user: UserSummaryDTO; size?: number }) {
|
|
||||||
const shape = { width: size, height: size, borderRadius: size / 2 };
|
const shape = { width: size, height: size, borderRadius: size / 2 };
|
||||||
if (user.avatarUrl) return <Image source={user.avatarUrl} style={shape} contentFit="cover" />;
|
const face = user.avatarUrl ? (
|
||||||
return (
|
<Image source={user.avatarUrl} style={shape} contentFit="cover" />
|
||||||
<View style={[styles.empty, shape]}>
|
) : (
|
||||||
|
<LinearGradient colors={['#4a1278', '#1872cf']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={[styles.empty, shape]}>
|
||||||
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
|
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
);
|
||||||
|
if (!live) return face;
|
||||||
|
|
||||||
|
const ring = size + 6;
|
||||||
|
return (
|
||||||
|
<View style={{ width: ring, height: ring, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, spin]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#7b2ff7', '#2f8cff', colors.live, '#7b2ff7']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={{ width: ring, height: ring, borderRadius: ring / 2 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
<View style={{ borderRadius: (size + 2) / 2, borderWidth: 2, borderColor: colors.bg }}>{face}</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
empty: { backgroundColor: colors.surface2, alignItems: 'center', justifyContent: 'center' },
|
empty: { alignItems: 'center', justifyContent: 'center' },
|
||||||
letter: { color: colors.muted, fontWeight: '700' },
|
letter: { color: '#fff', fontFamily: fonts.display },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
cancelAnimation,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withDelay,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
const BARS = [
|
||||||
|
{ duration: 520, delay: 0 },
|
||||||
|
{ duration: 380, delay: 120 },
|
||||||
|
{ duration: 640, delay: 60 },
|
||||||
|
{ duration: 450, delay: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Bar({ playing, duration, delay, color, height }: { playing: boolean; duration: number; delay: number; color: string; height: number }) {
|
||||||
|
const scale = useSharedValue(0.3);
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing) {
|
||||||
|
scale.set(withDelay(delay, withRepeat(withTiming(1, { duration, easing: Easing.inOut(Easing.quad) }), -1, true)));
|
||||||
|
} else {
|
||||||
|
cancelAnimation(scale);
|
||||||
|
scale.set(withTiming(0.3, { duration: 200 }));
|
||||||
|
}
|
||||||
|
return () => cancelAnimation(scale);
|
||||||
|
}, [playing, duration, delay, scale]);
|
||||||
|
const style = useAnimatedStyle(() => ({ transform: [{ translateY: (height * (1 - scale.value)) / 2 }, { scaleY: scale.value }] }));
|
||||||
|
return <Animated.View style={[{ width: 3, height, borderRadius: 2, backgroundColor: color }, style]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four bars that dance while music is playing and rest when it isn't. */
|
||||||
|
export function Equalizer({ playing, color = colors.live, height = 12 }: { playing: boolean; color?: string; height?: number }) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 2, height }} accessibilityElementsHidden>
|
||||||
|
{BARS.map((b, i) => (
|
||||||
|
<Bar key={i} playing={playing} color={color} height={height} {...b} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import type { TrackSearchResultDTO } from '@musicshare/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
|
import { Body, Button, Card, Field, Heading } from './ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a song and pin it to your profile with an optional note.
|
||||||
|
* `bare` drops the card and heading (for onboarding, which has its own title).
|
||||||
|
*/
|
||||||
|
export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: () => void }) {
|
||||||
|
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);
|
||||||
|
onSaved?.();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.result}>
|
||||||
|
{selected.artworkUrl && <Image source={selected.artworkUrl} style={styles.thumb} />}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.title} 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bare) return <View style={{ gap: space.md }}>{body}</View>;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Heading>Featured song</Heading>
|
||||||
|
<Body muted style={styles.small}>
|
||||||
|
Pin a song to your profile, with an optional note about why.
|
||||||
|
</Body>
|
||||||
|
{body}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
small: { fontSize: 13 },
|
||||||
|
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeInDown,
|
||||||
|
FadeOut,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSequence,
|
||||||
|
withSpring,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { successHaptic, tapHaptic } from '@/lib/haptics';
|
||||||
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
|
import { Equalizer } from './Equalizer';
|
||||||
import { TrackCard } from './TrackCard';
|
import { TrackCard } from './TrackCard';
|
||||||
import { Card } from './ui';
|
|
||||||
|
|
||||||
export function NowPlayingCard({
|
export function NowPlayingCard({
|
||||||
np,
|
np,
|
||||||
@@ -19,20 +32,43 @@ export function NowPlayingCard({
|
|||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
onTogglePin?: () => void;
|
onTogglePin?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
// Star "pop" when pinned.
|
||||||
|
const star = useSharedValue(1);
|
||||||
|
const wasPinned = useRef(pinned);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pinned && !wasPinned.current) star.set(withSequence(withSpring(1.6, { damping: 6, stiffness: 400 }), withSpring(1)));
|
||||||
|
wasPinned.current = pinned;
|
||||||
|
}, [pinned, star]);
|
||||||
|
const starStyle = useAnimatedStyle(() => ({ transform: [{ scale: star.value }] }));
|
||||||
|
|
||||||
|
const togglePin = () => {
|
||||||
|
if (pinned) tapHaptic();
|
||||||
|
else successHaptic();
|
||||||
|
onTogglePin?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card highlighted={pinned}>
|
<View style={[styles.card, pinned && styles.pinned]}>
|
||||||
|
{/* The card glows with its album art; crossfades when the song changes. */}
|
||||||
|
{np.track.artworkUrl && (
|
||||||
|
<Animated.View key={np.track.artworkUrl} style={StyleSheet.absoluteFill} entering={FadeIn.duration(700)} exiting={FadeOut.duration(500)}>
|
||||||
|
<Image source={np.track.artworkUrl} style={styles.backdrop} blurRadius={40} contentFit="cover" />
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
<LinearGradient colors={['rgba(7,7,11,0.05)', 'rgba(7,7,11,0.6)']} style={StyleSheet.absoluteFill} />
|
||||||
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
{showUser && (
|
{showUser && (
|
||||||
<Pressable style={styles.user} onPress={() => router.push(`/u/${np.user.username}`)} accessibilityRole="link">
|
<Pressable style={styles.user} onPress={() => router.push(`/u/${np.user.username}`)} accessibilityRole="link">
|
||||||
<Avatar user={np.user} size={28} />
|
<Avatar user={np.user} size={30} live={np.isPlaying} />
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
{np.user.displayName ?? np.user.username}
|
{np.user.displayName ?? np.user.username}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
<View style={styles.statusRow}>
|
<View style={styles.statusRow}>
|
||||||
{np.isPlaying && <View style={styles.dot} />}
|
{np.isPlaying && <Equalizer playing />}
|
||||||
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
|
<Text style={[styles.status, np.isPlaying && styles.live]} numberOfLines={1}>
|
||||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||||
<Text
|
<Text
|
||||||
style={[styles.via, np.sourceUrl && styles.viaLink]}
|
style={[styles.via, np.sourceUrl && styles.viaLink]}
|
||||||
@@ -43,29 +79,44 @@ export function NowPlayingCard({
|
|||||||
</Text>
|
</Text>
|
||||||
{onTogglePin && (
|
{onTogglePin && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onTogglePin}
|
onPress={togglePin}
|
||||||
hitSlop={10}
|
hitSlop={12}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={pinned ? 'Unpin from top of feed' : 'Pin to top of feed'}
|
accessibilityLabel={pinned ? 'Unpin from top of feed' : 'Pin to top of feed'}
|
||||||
accessibilityState={{ selected: pinned }}>
|
accessibilityState={{ selected: pinned }}>
|
||||||
<Text style={[styles.pin, pinned && { color: colors.pin }]}>{pinned ? '★' : '☆'}</Text>
|
<Animated.Text style={[styles.pin, pinned && styles.pinOn, starStyle]}>{pinned ? '★' : '☆'}</Animated.Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TrackCard track={np.track} preferred={preferred} />
|
|
||||||
</Card>
|
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
|
||||||
|
<TrackCard track={np.track} preferred={preferred} />
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: space.lg,
|
||||||
|
gap: space.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
pinned: { borderColor: 'rgba(251,191,36,0.55)' },
|
||||||
|
backdrop: { position: 'absolute', top: '-40%', left: '-40%', width: '180%', height: '180%', opacity: 0.55 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
|
||||||
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
|
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
|
||||||
name: { color: colors.text, fontWeight: '700', fontSize: 15, flexShrink: 1 },
|
name: { color: colors.text, fontWeight: '700', fontSize: 15, flexShrink: 1 },
|
||||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs, flexShrink: 0 },
|
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs + 2, flexShrink: 1 },
|
||||||
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.live },
|
status: { color: colors.muted, fontSize: 13, flexShrink: 1 },
|
||||||
status: { color: colors.muted, fontSize: 13 },
|
live: { color: colors.live, fontWeight: '600' },
|
||||||
via: { color: colors.muted },
|
via: { color: colors.muted, fontWeight: '400' },
|
||||||
viaLink: { textDecorationLine: 'underline' },
|
viaLink: { textDecorationLine: 'underline' },
|
||||||
pin: { color: colors.muted, fontSize: 20, marginLeft: space.xs },
|
pin: { color: colors.muted, fontSize: 21, marginLeft: space.xs },
|
||||||
|
pinOn: { color: colors.pin, textShadowColor: 'rgba(251,191,36,0.6)', textShadowRadius: 10 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
import { Screen } from './Screen';
|
||||||
|
|
||||||
|
const TOTAL = 4;
|
||||||
|
|
||||||
|
/** Shared frame for onboarding screens: progress dots and an entrance animation. */
|
||||||
|
export function OnboardingStep({ step, children }: { step: number; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
||||||
|
<View style={styles.dots} accessibilityLabel={`Step ${step} of ${TOTAL}`}>
|
||||||
|
{Array.from({ length: TOTAL }, (_, i) => (
|
||||||
|
<View key={i} style={[styles.dot, i < step && styles.dotDone, i === step - 1 && styles.dotCurrent]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Animated.View entering={FadeInDown.duration(450).springify().damping(18)} style={styles.body}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
content: { flexGrow: 1, gap: space.xl, paddingTop: space.xl },
|
||||||
|
body: { flex: 1, gap: space.xl, justifyContent: 'center' },
|
||||||
|
dots: { flexDirection: 'row', gap: space.sm, alignSelf: 'center' },
|
||||||
|
dot: { width: 8, height: 8, borderRadius: radius.pill, backgroundColor: colors.surface2 },
|
||||||
|
dotDone: { backgroundColor: colors.accent },
|
||||||
|
dotCurrent: { width: 24, backgroundColor: colors.accent2 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import Svg, { Path, Rect } from 'react-native-svg';
|
||||||
|
import { qrShape } from '@musicshare/shared';
|
||||||
|
|
||||||
|
/** Black-on-white QR code (scanners need the contrast) with the standard 4-module quiet zone. */
|
||||||
|
export function QrCode({ value, size = 240 }: { value: string; size?: number }) {
|
||||||
|
const shape = useMemo(() => qrShape(value), [value]);
|
||||||
|
const quiet = 4;
|
||||||
|
const total = shape.size + quiet * 2;
|
||||||
|
return (
|
||||||
|
<View accessible accessibilityLabel={`QR code for ${value}`} style={{ width: size, height: size, borderRadius: 16, overflow: 'hidden' }}>
|
||||||
|
<Svg width={size} height={size} viewBox={`${-quiet} ${-quiet} ${total} ${total}`}>
|
||||||
|
<Rect x={-quiet} y={-quiet} width={total} height={total} fill="#ffffff" />
|
||||||
|
<Path d={shape.path} fill="#000000" />
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RefreshControl, ScrollView, StyleSheet, type ScrollViewProps } from 'react-native';
|
import { RefreshControl, ScrollView, StyleSheet, type ScrollViewProps } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, space } from '@/lib/theme';
|
||||||
|
import { AmbientBackground } from './AmbientBackground';
|
||||||
|
|
||||||
/** Scrollable, safe-area-aware page with optional pull-to-refresh. */
|
/** Scrollable, safe-area-aware page with optional pull-to-refresh. */
|
||||||
export function Screen({
|
export function Screen({
|
||||||
@@ -13,6 +14,7 @@ export function Screen({
|
|||||||
}: ScrollViewProps & { onRefresh?: () => void; refreshing?: boolean; edges?: ('top' | 'bottom')[] }) {
|
}: ScrollViewProps & { onRefresh?: () => void; refreshing?: boolean; edges?: ('top' | 'bottom')[] }) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={edges}>
|
<SafeAreaView style={styles.safe} edges={edges}>
|
||||||
|
<AmbientBackground />
|
||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
contentContainerStyle={[styles.content, contentContainerStyle]}
|
contentContainerStyle={[styles.content, contentContainerStyle]}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Image } from 'expo-image';
|
|||||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +46,7 @@ export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: St
|
|||||||
const { url, exact } = openUrl(track, s);
|
const { url, exact } = openUrl(track, s);
|
||||||
return (
|
return (
|
||||||
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
||||||
<Text style={[styles.service, !exact && { opacity: 0.6 }]}>{SERVICE_LABELS[s]}</Text>
|
<Text style={[styles.service, !exact && { opacity: 0.55 }]}>{SERVICE_LABELS[s]}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -57,15 +57,32 @@ export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: St
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
track: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
|
track: { flexDirection: 'row', gap: space.md + 2, alignItems: 'flex-start' },
|
||||||
artwork: { width: 88, height: 88, borderRadius: radius.md, backgroundColor: colors.surface2 },
|
artwork: {
|
||||||
artworkEmpty: { alignItems: 'center', justifyContent: 'center' },
|
width: 96,
|
||||||
note: { color: colors.muted, fontSize: 28 },
|
height: 96,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.surface2,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
|
},
|
||||||
|
artworkEmpty: { alignItems: 'center', justifyContent: 'center', backgroundColor: '#3a1a6e' },
|
||||||
|
note: { color: 'rgba(255,255,255,0.85)', fontSize: 30 },
|
||||||
body: { flex: 1, minWidth: 0, gap: 2 },
|
body: { flex: 1, minWidth: 0, gap: 2 },
|
||||||
title: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
title: { color: colors.text, fontFamily: fonts.display, fontSize: 18, letterSpacing: -0.2 },
|
||||||
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||||
muted: { color: colors.muted },
|
muted: { color: colors.muted },
|
||||||
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
||||||
services: { flexDirection: 'row', flexWrap: 'wrap', columnGap: space.md, rowGap: space.xs, marginTop: space.sm },
|
services: { flexDirection: 'row', flexWrap: 'wrap', gap: space.xs + 2, marginTop: space.sm + 2 },
|
||||||
service: { color: colors.muted, fontSize: 12 },
|
service: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 12,
|
||||||
|
paddingHorizontal: space.sm + 2,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
|
||||||
|
import { Points, SetupActions, SetupHeader, type SetupProps } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android: one toggle covers every music app. This doubles as the prominent
|
||||||
|
* disclosure Google Play requires before the notification-access screen.
|
||||||
|
*/
|
||||||
|
export function AndroidShareSetup({ onDone, skipLabel }: SetupProps) {
|
||||||
|
const { granted, playback } = useReader();
|
||||||
|
const [synced, setSynced] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!granted) return;
|
||||||
|
syncDevice()
|
||||||
|
.catch((err) => showError(err, "Couldn't finish setup"))
|
||||||
|
.finally(() => setSynced(true));
|
||||||
|
}, [granted]);
|
||||||
|
|
||||||
|
if (granted) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader
|
||||||
|
title="You're all set"
|
||||||
|
subtitle={
|
||||||
|
playback?.title
|
||||||
|
? `Friends can see you're listening to ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''}.`
|
||||||
|
: 'Play something in Spotify, YouTube Music, Apple Music or any music app and your friends will see it.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SetupActions primary="Continue" loading={!synced} onPrimary={onDone} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader
|
||||||
|
title="Share what you’re playing"
|
||||||
|
subtitle="Spotify, YouTube Music, Apple Music, TIDAL, Deezer, SoundCloud and more all work automatically. No accounts to link."
|
||||||
|
/>
|
||||||
|
<Points
|
||||||
|
items={[
|
||||||
|
'We read the song, artist and album from music apps, and nothing else.',
|
||||||
|
'We never see your notifications, messages or other apps.',
|
||||||
|
'Android will call this “notification access”. That’s the only way it lets apps see what’s playing.',
|
||||||
|
'Turn it off any time in Settings.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SetupActions primary="Turn on" onPrimary={openReaderSettings} onSkip={onDone} skipLabel={skipLabel} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { mutate } from 'swr';
|
||||||
|
import { AppleMusicPermissionError, connectAppleMusic } from '@/lib/appleMusic';
|
||||||
|
import { ErrorText, Points, SetupActions, SetupHeader, type SetupProps } from './common';
|
||||||
|
|
||||||
|
/** iPhone: Apple Music is one system prompt. */
|
||||||
|
export function AppleMusicSetup({ onDone, skipLabel }: SetupProps) {
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
setConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await connectAppleMusic();
|
||||||
|
await mutate('/connections');
|
||||||
|
setConnected(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof AppleMusicPermissionError ? err.message : "Couldn't connect Apple Music. Try again.");
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader title="You're all set" subtitle="Play something in Apple Music and your friends will see it." />
|
||||||
|
<SetupActions primary="Continue" onPrimary={onDone} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader
|
||||||
|
title="Share what you’re playing"
|
||||||
|
subtitle="Connect Apple Music and your friends see what you play. It takes one tap."
|
||||||
|
/>
|
||||||
|
<Points items={['We see the songs you play in Apple Music, and nothing else.', 'Pause sharing or disconnect any time.']} />
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
<SetupActions
|
||||||
|
primary="Connect Apple Music"
|
||||||
|
loading={connecting}
|
||||||
|
onPrimary={connect}
|
||||||
|
onSkip={onDone}
|
||||||
|
skipLabel={skipLabel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Linking } from 'react-native';
|
||||||
|
import { mutate } from 'swr';
|
||||||
|
import { SERVICE_LABELS } from '@musicshare/shared';
|
||||||
|
import { SignInError, signInWithService } from '@/lib/connect';
|
||||||
|
import { Button } from '../ui';
|
||||||
|
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
|
||||||
|
|
||||||
|
type Provider = 'plex' | 'lastfm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in to Plex, or to Last.fm for apps that can send plays there.
|
||||||
|
* (Spotify has its own SpotifySetup: via Last.fm or ListenBrainz.)
|
||||||
|
*/
|
||||||
|
export function ServiceSignInSetup({
|
||||||
|
service,
|
||||||
|
appName,
|
||||||
|
onDone,
|
||||||
|
skipLabel,
|
||||||
|
}: SetupProps & { service: Provider; appName?: string }) {
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if ((await signInWithService(service)) === 'connected') {
|
||||||
|
await mutate('/connections');
|
||||||
|
setDone(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof SignInError ? err.message : `Couldn't connect ${SERVICE_LABELS[service]}. Try again.`);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader
|
||||||
|
title="You're all set"
|
||||||
|
subtitle={`Play something on ${appName ?? SERVICE_LABELS[service]} and your friends will see it.`}
|
||||||
|
/>
|
||||||
|
<SetupActions primary="Continue" onPrimary={onDone} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service === 'lastfm') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader
|
||||||
|
title="Connect Last.fm"
|
||||||
|
subtitle={`${appName ?? 'Your app'} doesn’t let other iPhone apps see what’s playing. If it can send plays to Last.fm (TIDAL can, in its settings), sign in to Last.fm and we’ll share them.`}
|
||||||
|
/>
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
<SetupActions primary="Sign in with Last.fm" loading={busy} onPrimary={run} onSkip={onDone} skipLabel={skipLabel} />
|
||||||
|
<Button small title="New to Last.fm? Create an account" onPress={() => Linking.openURL('https://www.last.fm/join')} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader title="Share what you’re playing" subtitle={`Sign in to ${SERVICE_LABELS[service]} and your friends see what you play.`} />
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
<SetupActions primary={`Connect ${SERVICE_LABELS[service]}`} loading={busy} onPrimary={run} onSkip={onDone} skipLabel={skipLabel} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import type { ConnectionDTO } from '@musicshare/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||||
|
import { successHaptic } from '@/lib/haptics';
|
||||||
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
import { Body, Button, Field } from '../ui';
|
||||||
|
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
|
||||||
|
|
||||||
|
type Via = 'lastfm' | 'listenbrainz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify sends your plays to Last.fm or ListenBrainz (its own built-in link),
|
||||||
|
* and we read them from there. Spotify only lets small apps sign in a handful of
|
||||||
|
* accounts directly, so this is the route that works for everyone.
|
||||||
|
*/
|
||||||
|
export function SpotifySetup({ onDone, skipLabel }: SetupProps) {
|
||||||
|
const { data: connections = [] } = useSWR<ConnectionDTO[]>('/connections');
|
||||||
|
const connected = (p: Via) => connections.some((c) => c.provider === p && c.status === 'active');
|
||||||
|
const [via, setVia] = useState<Via | null>(connected('listenbrainz') && !connected('lastfm') ? 'listenbrainz' : null);
|
||||||
|
const [stage, setStage] = useState<'choose' | 'link' | 'done'>(connected('lastfm') || connected('listenbrainz') ? 'link' : 'choose');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const connectLastfm = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if ((await signInWithService('lastfm')) === 'connected') {
|
||||||
|
await mutate('/connections');
|
||||||
|
setVia('lastfm');
|
||||||
|
setStage('link');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof SignInError ? err.message : "Couldn't connect Last.fm. Try again.");
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectListenBrainz = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.connectUsername('listenbrainz', username.trim());
|
||||||
|
await mutate('/connections');
|
||||||
|
setStage('link');
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stage === 'done') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader title="You're all set" subtitle="Play something on Spotify. It'll show up for your friends within a minute or so." />
|
||||||
|
<SetupActions primary="Continue" onPrimary={onDone} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === 'link') {
|
||||||
|
const name = via === 'listenbrainz' ? 'ListenBrainz' : 'Last.fm';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader
|
||||||
|
title="Last step: link Spotify"
|
||||||
|
subtitle={
|
||||||
|
via === 'listenbrainz'
|
||||||
|
? 'On ListenBrainz, connect Spotify under Music services and approve. Spotify then sends your plays there, and we pick them up.'
|
||||||
|
: 'On Last.fm, tap Connect next to “Spotify Scrobbling” and approve. Spotify then sends your plays there, and we pick them up.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SetupActions
|
||||||
|
primary={`Open ${name}`}
|
||||||
|
onPrimary={() =>
|
||||||
|
openSpotifyLinkPage(via ?? 'lastfm').then(() => {
|
||||||
|
successHaptic();
|
||||||
|
setStage('done');
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSkip={onDone}
|
||||||
|
skipLabel="I'll do it later"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetupHeader
|
||||||
|
title="Connect Spotify"
|
||||||
|
subtitle="Spotify can send everything you play to Last.fm or ListenBrainz, and we share it from there. Pick one (both are free):"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Option
|
||||||
|
selected={via === 'lastfm'}
|
||||||
|
onPress={() => setVia('lastfm')}
|
||||||
|
title="Last.fm"
|
||||||
|
badge="Most popular"
|
||||||
|
detail="Sign in with one tap, then approve Spotify on Last.fm."
|
||||||
|
/>
|
||||||
|
<Option
|
||||||
|
selected={via === 'listenbrainz'}
|
||||||
|
onPress={() => setVia('listenbrainz')}
|
||||||
|
title="ListenBrainz"
|
||||||
|
badge="Open source"
|
||||||
|
detail="Free and run by a nonprofit. Enter your ListenBrainz username."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{via === 'listenbrainz' && (
|
||||||
|
<Field placeholder="ListenBrainz username" value={username} onChangeText={setUsername} autoCapitalize="none" autoCorrect={false} />
|
||||||
|
)}
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
|
||||||
|
<View style={{ gap: space.sm }}>
|
||||||
|
{via === 'lastfm' && <Button variant="primary" title="Sign in with Last.fm" loading={busy} onPress={connectLastfm} />}
|
||||||
|
{via === 'listenbrainz' && (
|
||||||
|
<Button variant="primary" title="Connect ListenBrainz" loading={busy} disabled={!username.trim()} onPress={connectListenBrainz} />
|
||||||
|
)}
|
||||||
|
<Button title={skipLabel ?? 'Not now'} onPress={onDone} />
|
||||||
|
</View>
|
||||||
|
<Body muted style={styles.small} onPress={() => Linking.openURL(via === 'listenbrainz' ? 'https://listenbrainz.org/login/' : 'https://www.last.fm/join')}>
|
||||||
|
No account yet? Create one free on {via === 'listenbrainz' ? 'ListenBrainz' : 'Last.fm'} first.
|
||||||
|
</Body>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Option({ selected, onPress, title, badge, detail }: { selected: boolean; onPress: () => void; title: string; badge: string; detail: string }) {
|
||||||
|
return (
|
||||||
|
<Pressable accessibilityRole="radio" accessibilityState={{ checked: selected }} onPress={onPress} style={[styles.option, selected && styles.optionSelected]}>
|
||||||
|
<View style={styles.optionHead}>
|
||||||
|
<Text style={styles.optionTitle}>{title}</Text>
|
||||||
|
<Text style={styles.badge}>{badge}</Text>
|
||||||
|
</View>
|
||||||
|
<Body muted style={styles.small}>
|
||||||
|
{detail}
|
||||||
|
</Body>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
option: { borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, padding: space.md, gap: space.xs },
|
||||||
|
optionSelected: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.16)' },
|
||||||
|
optionHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
optionTitle: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||||
|
badge: { color: colors.accent2, fontSize: 12, fontWeight: '700' },
|
||||||
|
small: { fontSize: 13 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import { colors, space } from '@/lib/theme';
|
||||||
|
import { Body, Button, Card, Title } from '../ui';
|
||||||
|
|
||||||
|
export interface SetupProps {
|
||||||
|
/** Called when the user finishes (connected) or moves on. */
|
||||||
|
onDone: () => void;
|
||||||
|
/** Label for the secondary "move on without connecting" button. */
|
||||||
|
skipLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupHeader({ title, subtitle }: { title: string; subtitle: string }) {
|
||||||
|
return (
|
||||||
|
<View style={{ gap: space.md }}>
|
||||||
|
<Title>{title}</Title>
|
||||||
|
<Body muted>{subtitle}</Body>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Points({ items }: { items: string[] }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Body key={item} style={styles.point}>
|
||||||
|
• {item}
|
||||||
|
</Body>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupActions({
|
||||||
|
primary,
|
||||||
|
onPrimary,
|
||||||
|
loading,
|
||||||
|
onSkip,
|
||||||
|
skipLabel = 'Not now',
|
||||||
|
}: {
|
||||||
|
primary: string;
|
||||||
|
onPrimary: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
onSkip?: () => void;
|
||||||
|
skipLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ gap: space.sm }}>
|
||||||
|
<Button variant="primary" title={primary} loading={loading} onPress={onPrimary} />
|
||||||
|
{onSkip && <Button title={skipLabel} onPress={onSkip} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorText = ({ children }: { children: string | null }) =>
|
||||||
|
children ? <Body style={{ color: colors.danger }}>{children}</Body> : null;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
point: { color: colors.text, fontSize: 14 },
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { forwardRef } from 'react';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { forwardRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -6,12 +7,15 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
|
type GestureResponderEvent,
|
||||||
type PressableProps,
|
type PressableProps,
|
||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
type TextProps,
|
type TextProps,
|
||||||
type ViewProps,
|
type ViewProps,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||||
|
import { tapHaptic } from '@/lib/haptics';
|
||||||
|
import { brandGradient, colors, fonts, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
export function Title({ style, ...props }: TextProps) {
|
export function Title({ style, ...props }: TextProps) {
|
||||||
return <Text style={[styles.title, style]} {...props} />;
|
return <Text style={[styles.title, style]} {...props} />;
|
||||||
@@ -33,6 +37,21 @@ export function Row({ style, ...props }: ViewProps) {
|
|||||||
return <View style={[styles.row, style]} {...props} />;
|
return <View style={[styles.row, style]} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Springy press feedback shared by buttons and tappable cards. */
|
||||||
|
export function usePressScale(to = 0.95) {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
return {
|
||||||
|
style,
|
||||||
|
onPressIn: () => {
|
||||||
|
scale.set(withSpring(to, { damping: 15, stiffness: 400 }));
|
||||||
|
},
|
||||||
|
onPressOut: () => {
|
||||||
|
scale.set(withSpring(1, { damping: 12, stiffness: 300 }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type Variant = 'primary' | 'secondary' | 'danger';
|
type Variant = 'primary' | 'secondary' | 'danger';
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
@@ -42,59 +61,83 @@ export function Button({
|
|||||||
small,
|
small,
|
||||||
style,
|
style,
|
||||||
disabled,
|
disabled,
|
||||||
|
onPress,
|
||||||
...props
|
...props
|
||||||
}: PressableProps & { title: string; variant?: Variant; loading?: boolean; small?: boolean; style?: ViewProps['style'] }) {
|
}: PressableProps & { title: string; variant?: Variant; loading?: boolean; small?: boolean; style?: ViewProps['style'] }) {
|
||||||
|
const press = usePressScale();
|
||||||
|
const inactive = disabled || loading;
|
||||||
|
const handlePress = (e: GestureResponderEvent) => {
|
||||||
|
if (variant === 'primary') tapHaptic();
|
||||||
|
onPress?.(e);
|
||||||
|
};
|
||||||
|
const label = loading ? (
|
||||||
|
<ActivityIndicator color={variant === 'secondary' ? colors.text : colors.accentText} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.buttonText, small && styles.buttonTextSmall, variant !== 'secondary' && { color: colors.accentText }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Animated.View style={[press.style, inactive && { opacity: 0.55 }, style]}>
|
||||||
accessibilityRole="button"
|
<Pressable
|
||||||
disabled={disabled || loading}
|
accessibilityRole="button"
|
||||||
style={({ pressed }) => [
|
accessibilityState={{ disabled: Boolean(inactive), busy: Boolean(loading) }}
|
||||||
styles.button,
|
disabled={inactive}
|
||||||
small && styles.buttonSmall,
|
onPressIn={press.onPressIn}
|
||||||
variant === 'primary' && styles.buttonPrimary,
|
onPressOut={press.onPressOut}
|
||||||
variant === 'danger' && styles.buttonDanger,
|
onPress={handlePress}
|
||||||
(pressed || disabled) && { opacity: 0.7 },
|
style={[styles.button, small && styles.buttonSmall, variant === 'danger' && styles.buttonDanger, variant === 'primary' && styles.buttonPrimary]}
|
||||||
style,
|
{...props}>
|
||||||
]}
|
{variant === 'primary' && (
|
||||||
{...props}>
|
<LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />
|
||||||
{loading ? (
|
)}
|
||||||
<ActivityIndicator color={variant === 'secondary' ? colors.text : colors.accentText} />
|
{label}
|
||||||
) : (
|
</Pressable>
|
||||||
<Text style={[styles.buttonText, small && styles.buttonTextSmall, variant !== 'secondary' && { color: colors.accentText }]}>
|
</Animated.View>
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(function Field(
|
export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(function Field(
|
||||||
{ label, style, ...props },
|
{ label, style, onFocus, onBlur, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: space.xs }}>
|
<View style={{ gap: space.xs }}>
|
||||||
{label && <Text style={styles.label}>{label}</Text>}
|
{label && <Text style={styles.label}>{label}</Text>}
|
||||||
<TextInput ref={ref} placeholderTextColor={colors.muted} style={[styles.input, style]} {...props} />
|
<TextInput
|
||||||
|
ref={ref}
|
||||||
|
placeholderTextColor={colors.muted}
|
||||||
|
style={[styles.input, focused && styles.inputFocused, style]}
|
||||||
|
onFocus={(e) => (setFocused(true), onFocus?.(e))}
|
||||||
|
onBlur={(e) => (setFocused(false), onBlur?.(e))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Chip({ label, selected, onPress }: { label: string; selected?: boolean; onPress: () => void }) {
|
export function Chip({ label, selected, onPress }: { label: string; selected?: boolean; onPress: () => void }) {
|
||||||
|
const press = usePressScale(0.92);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Animated.View style={press.style}>
|
||||||
accessibilityRole="button"
|
<Pressable
|
||||||
accessibilityState={{ selected }}
|
accessibilityRole="button"
|
||||||
onPress={onPress}
|
accessibilityState={{ selected }}
|
||||||
style={[styles.chip, selected && styles.chipSelected]}>
|
onPressIn={press.onPressIn}
|
||||||
<Text style={[styles.chipText, selected && { color: colors.accentText }]}>{label}</Text>
|
onPressOut={press.onPressOut}
|
||||||
</Pressable>
|
onPress={onPress}
|
||||||
|
style={[styles.chip, selected && styles.chipSelected]}>
|
||||||
|
{selected && <LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />}
|
||||||
|
<Text style={[styles.chipText, selected && { color: colors.accentText }]}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
title: { color: colors.text, fontSize: 28, fontWeight: '800', letterSpacing: -0.5 },
|
title: { color: colors.text, fontSize: 30, fontFamily: fonts.display, letterSpacing: -0.6 },
|
||||||
heading: { color: colors.text, fontSize: 17, fontWeight: '700', marginBottom: space.xs },
|
heading: { color: colors.text, fontSize: 18, fontFamily: fonts.display, marginBottom: space.xs },
|
||||||
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
||||||
muted: { color: colors.muted },
|
muted: { color: colors.muted },
|
||||||
card: {
|
card: {
|
||||||
@@ -104,28 +147,37 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
padding: space.lg,
|
padding: space.lg,
|
||||||
gap: space.md,
|
gap: space.md,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
cardHighlighted: { borderColor: colors.accent, borderWidth: 1 },
|
cardHighlighted: { borderColor: 'rgba(251,191,36,0.55)', borderWidth: 1 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexWrap: 'wrap' },
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexWrap: 'wrap' },
|
||||||
button: {
|
button: {
|
||||||
minHeight: 44,
|
minHeight: 46,
|
||||||
paddingHorizontal: space.lg,
|
paddingHorizontal: space.lg + 2,
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.pill,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.surface2,
|
backgroundColor: colors.surface2,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: colors.borderStrong,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
buttonSmall: { minHeight: 36, paddingHorizontal: space.md + 2 },
|
||||||
|
buttonPrimary: {
|
||||||
|
borderColor: 'transparent',
|
||||||
|
shadowColor: '#5b4cf0',
|
||||||
|
shadowOpacity: 0.6,
|
||||||
|
shadowRadius: 14,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
elevation: 6,
|
||||||
},
|
},
|
||||||
buttonSmall: { minHeight: 34, paddingHorizontal: space.md },
|
|
||||||
buttonPrimary: { backgroundColor: colors.accent, borderColor: colors.accent },
|
|
||||||
buttonDanger: { backgroundColor: colors.danger, borderColor: colors.danger },
|
buttonDanger: { backgroundColor: colors.danger, borderColor: colors.danger },
|
||||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||||
buttonTextSmall: { fontSize: 13 },
|
buttonTextSmall: { fontSize: 13 },
|
||||||
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
||||||
input: {
|
input: {
|
||||||
minHeight: 44,
|
minHeight: 48,
|
||||||
paddingHorizontal: space.md,
|
paddingHorizontal: space.md + 2,
|
||||||
paddingVertical: space.sm,
|
paddingVertical: space.sm,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -134,6 +186,7 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
|
inputFocused: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.10)' },
|
||||||
chip: {
|
chip: {
|
||||||
paddingHorizontal: space.md,
|
paddingHorizontal: space.md,
|
||||||
paddingVertical: space.sm,
|
paddingVertical: space.sm,
|
||||||
@@ -141,7 +194,8 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
backgroundColor: colors.surface2,
|
backgroundColor: colors.surface2,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
chipSelected: { backgroundColor: colors.accent, borderColor: colors.accent },
|
chipSelected: { borderColor: 'transparent' },
|
||||||
chipText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
chipText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ import Constants from 'expo-constants';
|
|||||||
const devHost = Constants.expoConfig?.hostUri?.split(':')[0];
|
const devHost = Constants.expoConfig?.hostUri?.split(':')[0];
|
||||||
|
|
||||||
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? (devHost ? `http://${devHost}:4000` : 'http://localhost:4000');
|
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? (devHost ? `http://${devHost}:4000` : 'http://localhost:4000');
|
||||||
|
|
||||||
|
/** The website, for shareable profile links and QR codes. In development, the web app on port 3737. */
|
||||||
|
export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? (devHost ? `http://${devHost}:3737` : API_URL.replace(/:4000$/, ':3737'));
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import { SPOTIFY_LINK_PAGES } from '@musicshare/shared';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export type SignInResult = 'connected' | 'cancelled';
|
||||||
|
|
||||||
|
export class SignInError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in on the service's own site in an auth browser and come back through
|
||||||
|
* the app's deep link.
|
||||||
|
*/
|
||||||
|
export async function signInWithService(provider: 'plex' | 'lastfm'): Promise<SignInResult> {
|
||||||
|
// 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 'cancelled';
|
||||||
|
const error = Linking.parse(result.url).queryParams?.error;
|
||||||
|
if (typeof error === 'string') throw new SignInError(error);
|
||||||
|
return 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where users approve "Spotify Scrobbling" (Last.fm) or the Spotify link (ListenBrainz). */
|
||||||
|
export const openSpotifyLinkPage = (on: keyof typeof SPOTIFY_LINK_PAGES = 'lastfm') =>
|
||||||
|
WebBrowser.openBrowserAsync(SPOTIFY_LINK_PAGES[on]);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
/** Light feedback for taps on primary actions. Never throws (some devices have no haptics). */
|
||||||
|
export const tapHaptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||||
|
|
||||||
|
/** A satisfying "done" for follow, pin, connect, scan. */
|
||||||
|
export const successHaptic = () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||||
|
|
||||||
|
export const selectionHaptic = () => Haptics.selectionAsync().catch(() => {});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { MeDTO, StreamingService } from '@musicshare/shared';
|
||||||
|
import { api } from './api';
|
||||||
|
import { markSetupSeen } from './setup';
|
||||||
|
|
||||||
|
/** What people pick on the first onboarding screen. `service` is where friends' songs open. */
|
||||||
|
export interface ListenApp {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
service?: StreamingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LISTEN_APPS: ListenApp[] = [
|
||||||
|
{ id: 'spotify', label: 'Spotify', service: 'spotify' },
|
||||||
|
{ id: 'apple_music', label: 'Apple Music', service: 'apple_music' },
|
||||||
|
{ id: 'youtube_music', label: 'YouTube Music', service: 'youtube_music' },
|
||||||
|
{ id: 'tidal', label: 'TIDAL', service: 'tidal' },
|
||||||
|
{ id: 'deezer', label: 'Deezer', service: 'deezer' },
|
||||||
|
{ id: 'soundcloud', label: 'SoundCloud' },
|
||||||
|
{ id: 'amazon', label: 'Amazon Music' },
|
||||||
|
{ id: 'plex', label: 'Plex / Plexamp' },
|
||||||
|
{ id: 'jellyfin', label: 'Jellyfin' },
|
||||||
|
{ id: 'other', label: 'Something else' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const listenApp = (id: string | undefined) => LISTEN_APPS.find((a) => a.id === id) ?? LISTEN_APPS[LISTEN_APPS.length - 1]!;
|
||||||
|
|
||||||
|
/** Done (or skipped through): never show onboarding again, on any device. */
|
||||||
|
export async function finishOnboarding(setMe: (me: MeDTO) => void) {
|
||||||
|
await markSetupSeen();
|
||||||
|
setMe(await api.updateMe({ onboarded: true }));
|
||||||
|
}
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
/** Mirrors the web palette (apps/web/src/app/globals.css). */
|
/** Mirrors the web palette (apps/web/src/app/globals.css). Brand colours come from the logo. */
|
||||||
export const colors = {
|
export const colors = {
|
||||||
bg: '#0e0f13',
|
bg: '#07070b',
|
||||||
surface: '#171920',
|
surface: 'rgba(255,255,255,0.05)',
|
||||||
surface2: '#20232c',
|
surfaceSolid: '#12121a',
|
||||||
border: '#2a2e39',
|
surface2: 'rgba(255,255,255,0.09)',
|
||||||
text: '#eceef3',
|
border: 'rgba(255,255,255,0.10)',
|
||||||
muted: '#9097a6',
|
borderStrong: 'rgba(255,255,255,0.18)',
|
||||||
accent: '#7c5cff',
|
text: '#f4f3fa',
|
||||||
|
muted: '#9b99ae',
|
||||||
|
accent: '#7c4dff',
|
||||||
|
accent2: '#2f8cff',
|
||||||
accentText: '#ffffff',
|
accentText: '#ffffff',
|
||||||
live: '#3ddc84',
|
live: '#34d399',
|
||||||
danger: '#ff5c6c',
|
danger: '#fb7185',
|
||||||
pin: '#f5c542',
|
pin: '#fbbf24',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The logo's purple-to-blue, for buttons and highlights. */
|
||||||
|
export const brandGradient = ['#7b2ff7', '#5b4cf0', '#2f8cff'] as const;
|
||||||
|
|
||||||
|
export const fonts = {
|
||||||
|
display: 'SpaceGrotesk_700Bold',
|
||||||
|
displayMedium: 'SpaceGrotesk_500Medium',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 } as const;
|
export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 } as const;
|
||||||
export const radius = { sm: 6, md: 10, lg: 14, pill: 999 } as const;
|
export const radius = { sm: 8, md: 12, lg: 20, pill: 999 } as const;
|
||||||
|
|||||||