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]));