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
@@ -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>
);
}