A local Expo module listens to system media sessions (the only way Android exposes other apps' playback, gated by notification access; we never read notifications). It filters to known music apps, debounces and deduplicates changes, sends a heartbeat while playing, and delivers reports through WorkManager so they survive the app being closed. - One-screen onboarding that doubles as Google Play's prominent disclosure, opening straight to the app's toggle in Android settings - Only media notifications are pre-ticked on that settings screen - Device registration is single-flight (several screens react to the same permission change) and self-heals if the server revokes a token - Sign-out revokes the phone's token; resuming sharing re-sends the current track immediately - Pause controls in Settings and on the feed - Fix invisible tab icons and labels on Android - Keep Metro out of build output so it doesn't exhaust file watchers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
125 lines
5.0 KiB
TypeScript
125 lines
5.0 KiB
TypeScript
import { router } from 'expo-router';
|
||
import { useEffect, useState } from 'react';
|
||
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import useSWR from 'swr';
|
||
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||
import { SharingControls } from '@/components/SharingControls';
|
||
import { Body, Button, Card, Title } from '@/components/ui';
|
||
import { api } from '@/lib/api';
|
||
import { showError } from '@/lib/errors';
|
||
import { shouldOfferSetup, useReader } from '@/lib/nowPlayingReader';
|
||
import { useRealtime } from '@/lib/useRealtime';
|
||
import { useSession } from '@/lib/session';
|
||
import { colors, space } from '@/lib/theme';
|
||
|
||
export default function FeedScreen() {
|
||
const { me } = useSession();
|
||
const { data: feed, mutate, isLoading } = useSWR<FeedItemDTO[]>('/feed');
|
||
// Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly.
|
||
const { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
|
||
const reader = useReader();
|
||
|
||
// First run on Android: open the one-tap setup once (deferred so navigation has mounted).
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
shouldOfferSetup().then((offer) => offer && router.push('/share-setup'));
|
||
}, 400);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
useRealtime(true, (msg) => {
|
||
if (msg.type !== 'now_playing') return;
|
||
if (msg.data.user.id === me?.id) {
|
||
if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false });
|
||
return;
|
||
}
|
||
mutate(
|
||
(current = []) => {
|
||
const existing = current.find((n) => n.user.id === msg.data.user.id);
|
||
return sortFeed([{ ...msg.data, pinnedAt: existing?.pinnedAt ?? null }, ...current.filter((n) => n !== existing)]);
|
||
},
|
||
{ revalidate: false },
|
||
);
|
||
});
|
||
|
||
const togglePin = async (item: FeedItemDTO) => {
|
||
const pinnedAt = item.pinnedAt ? null : new Date().toISOString();
|
||
const previous = feed;
|
||
mutate(sortFeed((feed ?? []).map((n) => (n.user.id === item.user.id ? { ...n, pinnedAt } : n))), { revalidate: false });
|
||
try {
|
||
await (pinnedAt ? api.pin(item.user.username) : api.unpin(item.user.username));
|
||
} catch (err) {
|
||
mutate(previous, { revalidate: false });
|
||
showError(err, "Couldn't pin");
|
||
}
|
||
};
|
||
|
||
const refresh = async () => {
|
||
setRefreshing(true);
|
||
await Promise.all([mutate(), mutateMine()]).catch(() => {});
|
||
setRefreshing(false);
|
||
};
|
||
|
||
return (
|
||
<SafeAreaView style={styles.safe} edges={['top']}>
|
||
<FlatList
|
||
data={feed ?? []}
|
||
keyExtractor={(item) => item.user.id}
|
||
contentContainerStyle={styles.list}
|
||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
|
||
ListHeaderComponent={
|
||
<View style={styles.header}>
|
||
<SharingControls compact />
|
||
{reader.available && !reader.granted && (
|
||
<Card>
|
||
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
|
||
<Button small variant="primary" title="Turn on" onPress={() => router.push('/share-setup')} style={{ alignSelf: 'flex-start' }} />
|
||
</Card>
|
||
)}
|
||
{mine?.nowPlaying && (
|
||
<View style={{ gap: space.sm }}>
|
||
<Text style={styles.sectionLabel}>YOU</Text>
|
||
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
||
</View>
|
||
)}
|
||
<Title>Friends listening</Title>
|
||
{!me?.preferredService && (
|
||
<Card>
|
||
<Body>Pick your streaming service so songs open in the right app.</Body>
|
||
<Button small title="Choose service" onPress={() => router.push('/settings')} style={{ alignSelf: 'flex-start' }} />
|
||
</Card>
|
||
)}
|
||
</View>
|
||
}
|
||
ListEmptyComponent={
|
||
isLoading ? null : (
|
||
<Card>
|
||
<Body muted>Nothing yet. Follow some friends and their music will show up here live.</Body>
|
||
<Button small variant="primary" title="Find friends" onPress={() => router.push('/search')} style={{ alignSelf: 'flex-start' }} />
|
||
</Card>
|
||
)
|
||
}
|
||
renderItem={({ item }) => (
|
||
<NowPlayingCard
|
||
np={item}
|
||
preferred={me?.preferredService}
|
||
pinned={Boolean(item.pinnedAt)}
|
||
onTogglePin={() => togglePin(item)}
|
||
/>
|
||
)}
|
||
/>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
safe: { flex: 1, backgroundColor: colors.bg },
|
||
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 },
|
||
header: { gap: space.md, marginBottom: space.xs },
|
||
sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 },
|
||
});
|