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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user