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