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:
2026-10-03 17:37:30 -04:00
co-authored by Claude Opus 5.5
parent 23faffc251
commit 15ab815df6
26 changed files with 865 additions and 8 deletions
+4 -1
View File
@@ -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>
+20 -1
View File
@@ -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>
+34
View File
@@ -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]));
+7
View File
@@ -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 }} />
+75
View File
@@ -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 },
});
@@ -34,7 +34,7 @@ export function NowPlayingCard({
{np.isPlaying && <View style={styles.dot} />}
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
<Text style={styles.via}> · {SERVICE_LABELS[np.source]}</Text>
<Text style={styles.via}> · {np.sourceApp ?? SERVICE_LABELS[np.source]}</Text>
</Text>
{onTogglePin && (
<Pressable
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { pausedUntilLabel } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { resendCurrentPlayback } from '@/lib/nowPlayingReader';
import { useSession } from '@/lib/session';
import { Body, Button, Card, Heading, Row } from './ui';
/** Pause/resume sharing. Compact mode is the banner at the top of the feed. */
export function SharingControls({ compact }: { compact?: boolean }) {
const { me, setMe } = useSession();
const [busy, setBusy] = useState(false);
if (!me) return null;
const set = async (pause: '1h' | '8h' | 'indefinitely' | null) => {
setBusy(true);
try {
setMe(await api.setSharing(pause));
if (pause === null) resendCurrentPlayback();
} catch (err) {
showError(err);
}
setBusy(false);
};
if (compact) {
if (!me.sharingPausedUntil) return null;
return (
<Card>
<Body>Sharing is paused {pausedUntilLabel(me.sharingPausedUntil)}.</Body>
<Button small variant="primary" title="Resume" loading={busy} onPress={() => set(null)} style={{ alignSelf: 'flex-start' }} />
</Card>
);
}
return (
<Card>
<Heading>Sharing</Heading>
{me.sharingPausedUntil ? (
<>
<Body muted style={{ fontSize: 13 }}>
Paused {pausedUntilLabel(me.sharingPausedUntil)}. Friends don’t see what you play, and nothing is added to your history.
</Body>
<Button small variant="primary" title="Resume sharing" loading={busy} onPress={() => set(null)} style={{ alignSelf: 'flex-start' }} />
</>
) : (
<>
<Body muted style={{ fontSize: 13 }}>
Friends see what you’re playing. Pause any time.
</Body>
<Row>
<Button small title="1 hour" disabled={busy} onPress={() => set('1h')} />
<Button small title="Until tomorrow" disabled={busy} onPress={() => set('8h')} />
<Button small title="Until I turn it back on" disabled={busy} onPress={() => set('indefinitely')} />
</Row>
</>
)}
</Card>
);
}
+99
View File
@@ -0,0 +1,99 @@
import * as Device from 'expo-device';
import * as SecureStore from 'expo-secure-store';
import { useEffect, useState } from 'react';
import { AppState } from 'react-native';
import { ANDROID_MUSIC_APPS } from '@musicshare/shared';
import NowPlayingReader, { type NativePlayback } from '@/modules/now-playing-reader';
import { api } from './api';
import { API_URL } from './config';
const DEVICE_KEY = 'musicshare.device';
const SETUP_SEEN_KEY = 'musicshare.shareSetupSeen';
/** True on Android builds that include the native module (not iOS, web, or Expo Go). */
export const readerAvailable = NowPlayingReader !== null;
/** Dev-only escape hatch for testing with apps outside the music allowlist, e.g. "com.android.chrome". */
const extraApps = String(process.env.EXPO_PUBLIC_EXTRA_MEDIA_APPS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const allowedApps = [...Object.keys(ANDROID_MUSIC_APPS), ...extraApps];
type StoredDevice = { id: string; token: string };
async function storedDevice(): Promise<StoredDevice | null> {
const raw = await SecureStore.getItemAsync(DEVICE_KEY);
return raw ? (JSON.parse(raw) as StoredDevice) : null;
}
let syncing: Promise<void> | null = null;
/**
* Make sure this phone is registered and the native listener knows where to
* report. Cheap to call repeatedly (app start, returning from settings).
* Single-flight: several screens react to the same permission change, and
* concurrent calls would each register a device and race to store a token.
*/
export function syncDevice(): Promise<void> {
syncing ??= doSyncDevice().finally(() => {
syncing = null;
});
return syncing;
}
async function doSyncDevice(): Promise<void> {
if (!NowPlayingReader || !NowPlayingReader.isPermissionGranted()) return;
let device = await storedDevice();
if (!device || NowPlayingReader.needsReregistration()) {
const res = await api.registerDevice({ platform: 'android', name: Device.modelName ?? 'Android phone' });
device = { id: res.device.id, token: res.token };
await SecureStore.setItemAsync(DEVICE_KEY, JSON.stringify(device));
}
NowPlayingReader.configure(API_URL, device.token, allowedApps);
}
/** Sign-out: stop reporting and revoke this phone's token (while the session is still valid). */
export async function unregisterDevice(): Promise<void> {
NowPlayingReader?.clear();
const device = await storedDevice();
if (device) await api.removeDevice(device.id).catch(() => {});
await SecureStore.deleteItemAsync(DEVICE_KEY);
}
export const openReaderSettings = () => NowPlayingReader?.openPermissionSettings();
/** After resuming sharing, re-send what's playing so friends see it immediately. */
export const resendCurrentPlayback = () => NowPlayingReader?.resend();
export async function shouldOfferSetup(): Promise<boolean> {
if (!NowPlayingReader || NowPlayingReader.isPermissionGranted()) return false;
return (await SecureStore.getItemAsync(SETUP_SEEN_KEY)) === null;
}
export const markSetupSeen = () => SecureStore.setItemAsync(SETUP_SEEN_KEY, '1');
/** Live permission state and what the listener currently sees. Re-checks when the app returns to the foreground. */
export function useReader() {
const [granted, setGranted] = useState(() => NowPlayingReader?.isPermissionGranted() ?? false);
const [playback, setPlayback] = useState<NativePlayback | null>(() => NowPlayingReader?.getCurrent() ?? null);
useEffect(() => {
if (!NowPlayingReader) return;
const reader = NowPlayingReader;
const appState = AppState.addEventListener('change', (state) => {
if (state !== 'active') return;
const now = reader.isPermissionGranted();
setGranted(now);
if (now) syncDevice().catch(() => {});
});
const playbackSub = reader.addListener('onPlaybackChange', (e) => setPlayback(e.playback));
const statusSub = reader.addListener('onStatusChange', () => syncDevice().catch(() => {}));
return () => {
appState.remove();
playbackSub.remove();
statusSub.remove();
};
}, []);
return { available: readerAvailable, granted, playback };
}
+2
View File
@@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
import { api, setApiToken } from './api';
import { unregisterDevice } from './nowPlayingReader';
const TOKEN_KEY = 'musicshare.session';
@@ -59,6 +60,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
}, []);
const signOut = useCallback(async () => {
await unregisterDevice().catch(() => {});
await api.logout().catch(() => {});
await SecureStore.deleteItemAsync(TOKEN_KEY);
setApiToken(null);