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
+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 },
});