Add Expo mobile app for iOS and Android
Native tabs for feed, friend search, profile and settings, with feature parity with the web app. Sessions are stored in SecureStore, realtime disconnects in the background, and Spotify/Plex sign-in returns to the app via the URL scheme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { Button } from './ui';
|
||||
|
||||
/**
|
||||
* Links are https universal/app links, so Linking.openURL hands them to the
|
||||
* native Spotify / Apple Music / YouTube Music app when it's installed.
|
||||
*/
|
||||
const open = (url: string) => Linking.openURL(url).catch((e) => showError(e, "Couldn't open link"));
|
||||
|
||||
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||
const primary = preferred ? openUrl(track, preferred) : null;
|
||||
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
||||
|
||||
return (
|
||||
<View style={styles.track}>
|
||||
{track.artworkUrl ? (
|
||||
<Image source={track.artworkUrl} style={styles.artwork} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[styles.artwork, styles.artworkEmpty]}>
|
||||
<Text style={styles.note}>♪</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>
|
||||
{track.artist}
|
||||
{track.album ? <Text style={styles.muted}> · {track.album}</Text> : null}
|
||||
</Text>
|
||||
{preferred && primary && (
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title={`${primary.exact ? 'Open' : 'Search'} in ${SERVICE_LABELS[preferred]}`}
|
||||
onPress={() => open(primary.url)}
|
||||
style={styles.primary}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.services}>
|
||||
{others.map((s) => {
|
||||
const { url, exact } = openUrl(track, s);
|
||||
return (
|
||||
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
||||
<Text style={[styles.service, !exact && { opacity: 0.6 }]}>{SERVICE_LABELS[s]}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
|
||||
artwork: { width: 88, height: 88, borderRadius: radius.md, backgroundColor: colors.surface2 },
|
||||
artworkEmpty: { alignItems: 'center', justifyContent: 'center' },
|
||||
note: { color: colors.muted, fontSize: 28 },
|
||||
body: { flex: 1, minWidth: 0, gap: 2 },
|
||||
title: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||
muted: { color: colors.muted },
|
||||
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
||||
services: { flexDirection: 'row', flexWrap: 'wrap', columnGap: space.md, rowGap: space.xs, marginTop: space.sm },
|
||||
service: { color: colors.muted, fontSize: 12 },
|
||||
});
|
||||
Reference in New Issue
Block a user