Share what's playing in any Android music app
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
This commit is contained in:
@@ -6,7 +6,10 @@ export default function TabsLayout() {
|
||||
<NativeTabs
|
||||
backgroundColor={colors.bg}
|
||||
indicatorColor={colors.surface2}
|
||||
labelStyle={{ selected: { color: colors.text } }}
|
||||
// Android defaults icons and labels to black, which vanishes on the dark bar.
|
||||
iconColor={{ default: colors.muted, selected: colors.text }}
|
||||
labelStyle={{ default: { color: colors.muted }, selected: { color: colors.text } }}
|
||||
labelVisibilityMode="labeled"
|
||||
tintColor={colors.accent}>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Feed</NativeTabs.Trigger.Label>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
@@ -19,6 +21,16 @@ export default function FeedScreen() {
|
||||
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) {
|
||||
@@ -61,6 +73,13 @@ export default function FeedScreen() {
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
type TrackSearchResultDTO,
|
||||
} from '@musicshare/shared';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { SharingControls } from '@/components/SharingControls';
|
||||
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
@@ -25,7 +28,9 @@ export default function SettingsScreen() {
|
||||
<Screen>
|
||||
<Title>Settings</Title>
|
||||
<ProfileSection />
|
||||
<SharingControls />
|
||||
<PreferredService />
|
||||
<ThisPhone />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
<Account />
|
||||
@@ -80,6 +85,35 @@ function PreferredService() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Android: share from any music app on this phone (no-op elsewhere). */
|
||||
function ThisPhone() {
|
||||
const { available, granted, playback } = useReader();
|
||||
if (!available) return null;
|
||||
return (
|
||||
<Card>
|
||||
<Heading>This phone</Heading>
|
||||
{granted ? (
|
||||
<Body muted style={styles.small}>
|
||||
{playback?.title
|
||||
? `Sharing ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''} from ${playback.appName ?? 'a music app'}.`
|
||||
: 'On. Anything you play in a music app is shared.'}
|
||||
</Body>
|
||||
) : (
|
||||
<Body muted style={styles.small}>
|
||||
Share what you play in Spotify, YouTube Music, Apple Music and other music apps. No accounts needed.
|
||||
</Body>
|
||||
)}
|
||||
<Button
|
||||
small
|
||||
variant={granted ? 'secondary' : 'primary'}
|
||||
title={granted ? 'Turn off in Android settings' : 'Turn on'}
|
||||
onPress={granted ? openReaderSettings : () => router.push('/share-setup')}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Connections() {
|
||||
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections');
|
||||
const byProvider = new Map(connections.map((c) => [c.provider, c]));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppState, StyleSheet, View } from 'react-native';
|
||||
import { SWRConfig } from 'swr';
|
||||
import { Body, Button, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { syncDevice } from '@/lib/nowPlayingReader';
|
||||
import { SessionProvider, useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
@@ -35,6 +36,11 @@ function AppNavigator() {
|
||||
if (status !== 'loading') SplashScreen.hideAsync();
|
||||
}, [status]);
|
||||
|
||||
// Keep the Android listener pointed at this account (no-op elsewhere).
|
||||
useEffect(() => {
|
||||
if (signedIn) syncDevice().catch(() => {});
|
||||
}, [signedIn]);
|
||||
|
||||
if (status === 'loading') return null;
|
||||
if (status === 'offline') {
|
||||
return (
|
||||
@@ -69,6 +75,7 @@ function AppNavigator() {
|
||||
<Stack.Protected guard={signedIn}>
|
||||
<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.Protected>
|
||||
<Stack.Protected guard={!signedIn}>
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Body, Button, Card, Title } from '@/components/ui';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { markSetupSeen, openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
/**
|
||||
* One-time onboarding on Android. This doubles as the prominent disclosure Google
|
||||
* Play requires before sending users to the notification-access screen.
|
||||
*/
|
||||
export default function ShareSetupScreen() {
|
||||
const { granted, playback } = useReader();
|
||||
const [synced, setSynced] = useState(false);
|
||||
const finishing = granted && !synced;
|
||||
|
||||
useEffect(() => {
|
||||
markSetupSeen();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!granted) return;
|
||||
syncDevice()
|
||||
.catch((err) => showError(err, "Couldn't finish setup"))
|
||||
.finally(() => setSynced(true));
|
||||
}, [granted]);
|
||||
|
||||
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { flexGrow: 1, justifyContent: 'center', gap: space.xl },
|
||||
point: { color: colors.text, fontSize: 14 },
|
||||
});
|
||||
Reference in New Issue
Block a user