Connect Apple Music and Spotify-via-Last.fm on iPhone

- Local Expo module (Swift): MusicKit authorization, Music User Token via
  MusicUserTokenProvider, and live Music app now-playing while the app is
  open (MediaPlayer notifications), reported through the device endpoint
- One-tap Apple Music onboarding on iOS; Apple Music row in Settings
- Sign in with Last.fm, plus a guided Spotify-via-Last.fm flow when direct
  Spotify sign-in is capped
- Device registration moved to shared code used by Android and iOS
Not yet compiled for iOS (no Xcode here); needs an EAS build.

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-04 07:12:19 -04:00
co-authored by Claude Opus 5.5
parent ca97f1c3ac
commit 1d4aeee0dd
16 changed files with 522 additions and 78 deletions
+4 -1
View File
@@ -9,7 +9,10 @@
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"ios": { "ios": {
"icon": "./assets/expo.icon", "icon": "./assets/expo.icon",
"bundleIdentifier": "tech.chrisedwards.musicshare" "bundleIdentifier": "tech.chrisedwards.musicshare",
"infoPlist": {
"NSAppleMusicUsageDescription": "musicshare shows your friends what you play in Apple Music, and opens their songs for you."
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["AppleMusicModule"]
}
}
+3
View File
@@ -0,0 +1,3 @@
// iOS-only native module; the default export is null where it is unavailable.
export { default } from './src/AppleMusicModule';
export * from './src/AppleMusic.types';
@@ -0,0 +1,24 @@
Pod::Spec.new do |s|
s.name = 'AppleMusic'
s.version = '1.0.0'
s.summary = 'Apple Music authorization, user token and Music app now-playing'
s.description = 'Local Expo module for musicshare'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = {
:ios => '16.4',
:tvos => '16.4'
}
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.frameworks = 'MusicKit', 'MediaPlayer'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,96 @@
import ExpoModulesCore
import MediaPlayer
import MusicKit
/**
* Apple Music for the iOS app:
* - the one-tap "Allow access to Apple Music" prompt (MusicKit authorization)
* - the Music User Token the server needs to read recently played
* - live now-playing from the Music app while our app is open
* (iOS doesn't let apps observe the Music app in the background)
*/
public class AppleMusicModule: Module {
private var observers: [NSObjectProtocol] = []
public func definition() -> ModuleDefinition {
Name("AppleMusic")
Events("onNowPlayingChange")
Function("authorizationStatus") { () -> String in
Self.describe(MusicAuthorization.currentStatus)
}
AsyncFunction("requestAuthorization") { () async -> String in
Self.describe(await MusicAuthorization.request())
}
/// developerToken comes from our server (signed with the MusicKit key).
AsyncFunction("getUserToken") { (developerToken: String) async throws -> String in
try await MusicUserTokenProvider().userToken(for: developerToken, options: .ignoreCache)
}
AsyncFunction("getNowPlaying") { () async -> [String: Any] in
await MainActor.run { Self.snapshot() }
}
OnStartObserving {
DispatchQueue.main.async { self.startObserving() }
}
OnStopObserving {
DispatchQueue.main.async { self.stopObserving() }
}
}
private func startObserving() {
guard observers.isEmpty, MusicAuthorization.currentStatus == .authorized else { return }
let player = MPMusicPlayerController.systemMusicPlayer
player.beginGeneratingPlaybackNotifications()
let center = NotificationCenter.default
for name in [Notification.Name.MPMusicPlayerControllerNowPlayingItemDidChange,
Notification.Name.MPMusicPlayerControllerPlaybackStateDidChange] {
observers.append(center.addObserver(forName: name, object: player, queue: .main) { [weak self] _ in
self?.sendEvent("onNowPlayingChange", Self.snapshot())
})
}
}
private func stopObserving() {
observers.forEach { NotificationCenter.default.removeObserver($0) }
observers.removeAll()
MPMusicPlayerController.systemMusicPlayer.endGeneratingPlaybackNotifications()
}
/// Must be called on the main thread.
private static func snapshot() -> [String: Any] {
let player = MPMusicPlayerController.systemMusicPlayer
guard let item = player.nowPlayingItem, let title = item.title, !title.isEmpty else {
return ["state": "stopped"]
}
let state: String
switch player.playbackState {
case .playing, .seekingForward, .seekingBackward: state = "playing"
case .paused, .interrupted: state = "paused"
default: state = "stopped"
}
return [
"state": state,
"title": title,
"artist": item.artist ?? "",
"album": item.albumTitle ?? "",
"durationMs": item.playbackDuration * 1000,
"storeId": item.playbackStoreID,
]
}
private static func describe(_ status: MusicAuthorization.Status) -> String {
switch status {
case .authorized: return "authorized"
case .denied: return "denied"
case .restricted: return "restricted"
case .notDetermined: return "notDetermined"
@unknown default: return "unknown"
}
}
}
@@ -0,0 +1,16 @@
export type AppleMusicAuthorization = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unknown';
export type MusicAppPlayback =
| { state: 'stopped' }
| {
state: 'playing' | 'paused' | 'stopped';
title: string;
artist: string;
album: string;
durationMs: number;
storeId: string;
};
export type AppleMusicModuleEvents = {
onNowPlayingChange: (playback: MusicAppPlayback) => void;
};
@@ -0,0 +1,13 @@
import { NativeModule, requireOptionalNativeModule } from 'expo';
import type { AppleMusicAuthorization, AppleMusicModuleEvents, MusicAppPlayback } from './AppleMusic.types';
declare class AppleMusicModule extends NativeModule<AppleMusicModuleEvents> {
authorizationStatus(): AppleMusicAuthorization;
requestAuthorization(): Promise<AppleMusicAuthorization>;
getUserToken(developerToken: string): Promise<string>;
getNowPlaying(): Promise<MusicAppPlayback>;
}
/** iOS only. null on Android, web and Expo Go, so callers must feature-check. */
export default requireOptionalNativeModule<AppleMusicModule>('AppleMusic');
+12 -2
View File
@@ -3,13 +3,15 @@ import { useEffect, useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native'; import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr'; import useSWR from 'swr';
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared'; import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { NowPlayingCard } from '@/components/NowPlayingCard'; import { NowPlayingCard } from '@/components/NowPlayingCard';
import { SharingControls } from '@/components/SharingControls'; import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Title } from '@/components/ui'; import { Body, Button, Card, Title } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { showError } from '@/lib/errors'; import { showError } from '@/lib/errors';
import { shouldOfferSetup, useReader } from '@/lib/nowPlayingReader'; import { appleMusicAvailable } from '@/lib/appleMusic';
import { useReader } from '@/lib/nowPlayingReader';
import { shouldOfferSetup } from '@/lib/setup';
import { useRealtime } from '@/lib/useRealtime'; import { useRealtime } from '@/lib/useRealtime';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme'; import { colors, space } from '@/lib/theme';
@@ -22,6 +24,8 @@ export default function FeedScreen() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const reader = useReader(); const reader = useReader();
const { data: connections } = useSWR<ConnectionDTO[]>(appleMusicAvailable ? '/connections' : null);
const offerAppleMusic = appleMusicAvailable && connections !== undefined && connections.length === 0;
// First run on Android: open the one-tap setup once (deferred so navigation has mounted). // First run on Android: open the one-tap setup once (deferred so navigation has mounted).
useEffect(() => { useEffect(() => {
@@ -74,6 +78,12 @@ export default function FeedScreen() {
ListHeaderComponent={ ListHeaderComponent={
<View style={styles.header}> <View style={styles.header}>
<SharingControls compact /> <SharingControls compact />
{offerAppleMusic && (
<Card>
<Body>Connect Apple Music so friends see what you play. One tap.</Body>
<Button small variant="primary" title="Connect" onPress={() => router.push('/share-setup')} style={{ alignSelf: 'flex-start' }} />
</Card>
)}
{reader.available && !reader.granted && ( {reader.available && !reader.granted && (
<Card> <Card>
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body> <Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
+94 -19
View File
@@ -18,6 +18,7 @@ import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui'; import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { showError } from '@/lib/errors'; import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader'; import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { useDebounced } from '@/lib/useDebounced'; import { useDebounced } from '@/lib/useDebounced';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
@@ -127,18 +128,54 @@ function Connections() {
await mutate(); await mutate();
}; };
const connectOAuth = (provider: 'spotify' | 'plex') => /** Sign in on the service's site and come back via deep link. Returns true when connected. */
const signIn = async (provider: 'spotify' | 'plex' | 'lastfm'): Promise<boolean> => {
// Deep link back into the app; in Expo Go this is an exp:// URL, in builds musicshare://
const returnTo = Linking.createURL('settings');
const { url } = await api.authorizeUrl(provider, { client: 'mobile', returnTo });
const result = await WebBrowser.openAuthSessionAsync(url, returnTo);
if (result.type !== 'success') return false;
const error = Linking.parse(result.url).queryParams?.error;
if (error === 'spotify_limited') {
offerSpotifyViaLastfm();
return false;
}
if (typeof error === 'string') {
Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
return false;
}
return true;
};
const connectOAuth = (provider: 'spotify' | 'plex' | 'lastfm') => run(() => signIn(provider));
/**
* Spotify only lets small apps sign in a handful of accounts. Spotify's own
* Last.fm link works for everyone: sign in to Last.fm here, then approve
* "Spotify Scrobbling" on Last.fm.
*/
const linkSpotifyOnLastfm = () =>
WebBrowser.openBrowserAsync('https://www.last.fm/settings/applications').catch((e) => showError(e));
const setUpSpotifyViaLastfm = () =>
run(async () => { run(async () => {
// Deep link back into the app; in Expo Go this is an exp:// URL, in builds musicshare:// if (!byProvider.has('lastfm') && !(await signIn('lastfm'))) return;
const returnTo = Linking.createURL('settings'); Alert.alert('Last step: link Spotify', 'On the next page, tap Connect next to "Spotify Scrobbling" and approve.', [
const { url } = await api.authorizeUrl(provider, { client: 'mobile', returnTo }); { text: 'Open Last.fm', onPress: linkSpotifyOnLastfm },
const result = await WebBrowser.openAuthSessionAsync(url, returnTo); ]);
if (result.type === 'success') {
const error = Linking.parse(result.url).queryParams?.error;
if (typeof error === 'string') Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
}
}); });
function offerSpotifyViaLastfm() {
Alert.alert(
'Spotify sign-in is limited during beta',
"Spotify only allows a few accounts to sign in to new apps directly. You can still share Spotify through Last.fm: two quick approvals, and it works for everyone.",
[
{ text: 'Not now', style: 'cancel' },
{ text: 'Set up', onPress: setUpSpotifyViaLastfm },
],
);
}
const disconnect = (provider: NowPlayingProvider) => const disconnect = (provider: NowPlayingProvider) =>
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [ Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
{ text: 'Cancel', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
@@ -159,6 +196,16 @@ function Connections() {
const isActive = (p: NowPlayingProvider) => byProvider.get(p)?.status === 'active'; const isActive = (p: NowPlayingProvider) => byProvider.get(p)?.status === 'active';
const linkAppleMusic = () =>
run(async () => {
try {
await connectAppleMusic();
} catch (err) {
if (err instanceof AppleMusicPermissionError) Alert.alert('Apple Music access needed', err.message);
else throw err;
}
});
return ( return (
<Card> <Card>
<Heading>Music services</Heading> <Heading>Music services</Heading>
@@ -166,6 +213,20 @@ function Connections() {
Connect where you listen. Friends see what you&apos;re playing in real time. Connect where you listen. Friends see what you&apos;re playing in real time.
</Body> </Body>
{appleMusicAvailable && (
<View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Apple Music</Text>
{status('apple_music', 'One tap. Live while the app is open, recently played otherwise.')}
</View>
{isActive('apple_music') ? (
<Button small title="Disconnect" onPress={() => disconnect('apple_music')} />
) : (
<Button small variant="primary" title={byProvider.has('apple_music') ? 'Reconnect' : 'Connect'} onPress={linkAppleMusic} />
)}
</View>
)}
{(['spotify', 'plex'] as const).map((p) => ( {(['spotify', 'plex'] as const).map((p) => (
<View key={p} style={[styles.connectionColumn, styles.connection]}> <View key={p} style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
@@ -180,16 +241,30 @@ function Connections() {
</View> </View>
))} ))}
{(['lastfm', 'listenbrainz'] as const).map((p) => ( <View style={styles.connectionColumn}>
<UsernameConnection <View style={styles.connection}>
key={p} <View style={{ flex: 1 }}>
provider={p} <Text style={styles.provider}>Last.fm</Text>
status={status(p, p === 'lastfm' ? 'Anything that scrobbles: YouTube Music, Tidal, Deezer…' : 'Open-source scrobbling')} {status('lastfm', 'Also the way to share Spotify for everyone (via Spotify’s Last.fm link).')}
active={byProvider.has(p)} </View>
onConnect={(username) => run(() => api.connectUsername(p, username))} {byProvider.has('lastfm') ? (
onDisconnect={() => disconnect(p)} <Button small title="Disconnect" onPress={() => disconnect('lastfm')} />
/> ) : (
))} <Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
)}
</View>
{byProvider.has('lastfm') && (
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
)}
</View>
<UsernameConnection
provider="listenbrainz"
status={status('listenbrainz', 'Open-source scrobbling')}
active={byProvider.has('listenbrainz')}
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
onDisconnect={() => disconnect('listenbrainz')}
/>
<JellyfinConnection <JellyfinConnection
status={status('jellyfin', 'Sign in to your Jellyfin server. Your password isn’t stored.')} status={status('jellyfin', 'Sign in to your Jellyfin server. Your password isn’t stored.')}
+11 -1
View File
@@ -3,9 +3,11 @@ import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AppState, StyleSheet, View } from 'react-native'; import { AppState, StyleSheet, View } from 'react-native';
import { SWRConfig } from 'swr'; import useSWR, { SWRConfig } from 'swr';
import type { ConnectionDTO } from '@musicshare/shared';
import { Body, Button, Title } from '@/components/ui'; import { Body, Button, Title } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
import { syncDevice } from '@/lib/nowPlayingReader'; import { syncDevice } from '@/lib/nowPlayingReader';
import { SessionProvider, useSession } from '@/lib/session'; import { SessionProvider, useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme'; import { colors, space } from '@/lib/theme';
@@ -65,6 +67,7 @@ function AppNavigator() {
return () => sub.remove(); return () => sub.remove();
}, },
}}> }}>
{signedIn && appleMusicAvailable && <AppleMusicLive />}
<Stack <Stack
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: colors.bg }, headerStyle: { backgroundColor: colors.bg },
@@ -86,6 +89,13 @@ function AppNavigator() {
); );
} }
/** iOS: mirror the Music app live while the app is open, once Apple Music is connected. */
function AppleMusicLive() {
const { data: connections } = useSWR<ConnectionDTO[]>('/connections');
useAppleMusicLive(Boolean(connections?.some((c) => c.provider === 'apple_music' && c.status === 'active')));
return null;
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
offline: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: space.xl, gap: space.md }, offline: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: space.xl, gap: space.md },
}); });
+69 -12
View File
@@ -1,24 +1,83 @@
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { StyleSheet, View } from 'react-native'; import { Platform, StyleSheet, View } from 'react-native';
import { mutate } from 'swr';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
import { Body, Button, Card, Title } from '@/components/ui'; import { Body, Button, Card, Title } from '@/components/ui';
import { showError } from '@/lib/errors'; import { showError } from '@/lib/errors';
import { markSetupSeen, openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader'; import { AppleMusicPermissionError, connectAppleMusic } from '@/lib/appleMusic';
import { openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
import { markSetupSeen } from '@/lib/setup';
import { colors, space } from '@/lib/theme'; import { colors, space } from '@/lib/theme';
/** const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
* 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;
export default function ShareSetupScreen() {
useEffect(() => { useEffect(() => {
markSetupSeen(); markSetupSeen();
}, []); }, []);
return Platform.OS === 'ios' ? <AppleMusicSetup /> : <AndroidSetup />;
}
/** iPhone: Apple Music is one system prompt. Other services live in Settings. */
function AppleMusicSetup() {
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const connect = async () => {
setConnecting(true);
setError(null);
try {
await connectAppleMusic();
await mutate('/connections');
setConnected(true);
} catch (err) {
setError(err instanceof AppleMusicPermissionError ? err.message : "Couldn't connect Apple Music. Try again.");
}
setConnecting(false);
};
return (
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
<View style={{ gap: space.md }}>
<Title>{connected ? "You're all set" : 'Share what you’re playing'}</Title>
<Body muted>
{connected
? 'Play something in Apple Music and your friends will see it.'
: 'Connect Apple Music and your friends see what you play. It takes one tap.'}
</Body>
</View>
{!connected && (
<Card>
<Body style={styles.point}>• We see the songs you play in Apple Music, and nothing else.</Body>
<Body style={styles.point}>• Use Spotify or Plex? Connect them in Settings.</Body>
<Body style={styles.point}>• Pause sharing or disconnect any time.</Body>
</Card>
)}
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
<View style={{ gap: space.sm }}>
{connected ? (
<Button variant="primary" title="Done" onPress={close} />
) : (
<>
<Button variant="primary" title="Connect Apple Music" loading={connecting} onPress={connect} />
<Button title="Not now" onPress={close} />
</>
)}
</View>
</Screen>
);
}
/**
* Android: one toggle covers every music app. This screen doubles as the
* prominent disclosure Google Play requires before the notification-access screen.
*/
function AndroidSetup() {
const { granted, playback } = useReader();
const [synced, setSynced] = useState(false);
const finishing = granted && !synced;
useEffect(() => { useEffect(() => {
if (!granted) return; if (!granted) return;
@@ -27,8 +86,6 @@ export default function ShareSetupScreen() {
.finally(() => setSynced(true)); .finally(() => setSynced(true));
}, [granted]); }, [granted]);
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
return ( return (
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}> <Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
<View style={{ gap: space.md }}> <View style={{ gap: space.md }}>
+80
View File
@@ -0,0 +1,80 @@
import { useEffect } from 'react';
import { AppState } from 'react-native';
import AppleMusic, { type MusicAppPlayback } from '@/modules/apple-music';
import { api } from './api';
import { reportPlayback } from './device';
/** True on iOS builds that include the native module (not Android, web, or Expo Go). */
export const appleMusicAvailable = AppleMusic !== null;
export class AppleMusicPermissionError extends Error {}
/**
* The whole Apple Music setup: one system prompt. Asks for access, exchanges
* our developer token for the user's Music User Token, and hands it to the
* server, which then follows recently played in the background.
*/
export async function connectAppleMusic() {
if (!AppleMusic) throw new Error('Apple Music is only available in the iPhone app');
let status = AppleMusic.authorizationStatus();
if (status === 'notDetermined') status = await AppleMusic.requestAuthorization();
if (status !== 'authorized') {
throw new AppleMusicPermissionError(
status === 'restricted'
? 'Apple Music access is restricted on this iPhone (Screen Time or a device profile).'
: 'Turn on Media & Apple Music for musicshare in the Settings app, then try again.',
);
}
const { token } = await api.appleMusicDeveloperToken();
const musicUserToken = await AppleMusic.getUserToken(token);
return api.connectAppleMusic(musicUserToken);
}
const HEARTBEAT_MS = 4 * 60_000; // the server expires silent phones after 9 minutes
let lastSent: { key: string; at: number } | null = null;
function report(playback: MusicAppPlayback) {
const key = 'title' in playback ? `${playback.state}|${playback.title}|${playback.artist}` : 'stopped';
const now = Date.now();
if (lastSent?.key === key && (playback.state !== 'playing' || now - lastSent.at < HEARTBEAT_MS)) return;
if (!lastSent && key === 'stopped') return; // nothing was shared yet; no need to say "stopped"
lastSent = { key, at: now };
reportPlayback(
'title' in playback && playback.state !== 'stopped'
? {
app: 'com.apple.Music',
appName: 'Apple Music',
state: playback.state,
track: {
title: playback.title,
artist: playback.artist,
album: playback.album || null,
durationMs: playback.durationMs > 0 ? Math.round(playback.durationMs) : null,
},
}
: { app: 'com.apple.Music', appName: 'Apple Music', state: 'stopped' },
).catch(() => {
lastSent = null; // retry on the next change
});
}
/**
* While the app is open, mirror the Music app exactly. iOS doesn't let us watch
* it in the background; then the server's recently-played polling takes over.
*/
export function useAppleMusicLive(enabled: boolean) {
useEffect(() => {
if (!enabled || !AppleMusic || AppleMusic.authorizationStatus() !== 'authorized') return;
const music = AppleMusic;
const check = () => music.getNowPlaying().then(report).catch(() => {});
check();
const changes = music.addListener('onNowPlayingChange', report);
const foreground = AppState.addEventListener('change', (s) => s === 'active' && check());
const heartbeat = setInterval(check, HEARTBEAT_MS);
return () => {
changes.remove();
foreground.remove();
clearInterval(heartbeat);
};
}, [enabled]);
}
+65
View File
@@ -0,0 +1,65 @@
import * as Device from 'expo-device';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import type { DevicePlaybackInput } from '@musicshare/shared';
import { api } from './api';
import { API_URL } from './config';
/**
* This phone's device token: it can only report playback for the signed-in
* user (POST /devices/now-playing). Android's native listener and the iOS
* Music app observer both report through it.
*/
const DEVICE_KEY = 'musicshare.device';
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 registering: Promise<StoredDevice> | null = null;
/**
* Registered device for this phone, registering on first use.
* 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 ensureDevice({ replace = false } = {}): Promise<StoredDevice> {
registering ??= (async () => {
const existing = replace ? null : await storedDevice();
if (existing) return existing;
const res = await api.registerDevice({
platform: Platform.OS === 'ios' ? 'ios' : 'android',
name: Device.modelName ?? (Platform.OS === 'ios' ? 'iPhone' : 'Android phone'),
});
const device = { id: res.device.id, token: res.token };
await SecureStore.setItemAsync(DEVICE_KEY, JSON.stringify(device));
return device;
})().finally(() => {
registering = null;
});
return registering;
}
/** Sign-out: revoke this phone's token while the session is still valid. */
export async function unregisterDevice(): Promise<void> {
const device = await storedDevice();
if (device) await api.removeDevice(device.id).catch(() => {});
await SecureStore.deleteItemAsync(DEVICE_KEY);
}
/** Report playback from JS (iOS). Android reports natively so it works with the app closed. */
export async function reportPlayback(input: DevicePlaybackInput): Promise<void> {
const send = async (token: string) =>
fetch(`${API_URL}/devices/now-playing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(input),
});
let res = await send((await ensureDevice()).token);
// The server revoked this device (e.g. removed from another session): register again once.
if (res.status === 401) res = await send((await ensureDevice({ replace: true })).token);
if (!res.ok && res.status !== 401) throw new Error(`report failed (${res.status})`);
}
+6 -42
View File
@@ -1,14 +1,10 @@
import * as Device from 'expo-device';
import * as SecureStore from 'expo-secure-store';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AppState } from 'react-native'; import { AppState } from 'react-native';
import { ANDROID_MUSIC_APPS } from '@musicshare/shared'; import { ANDROID_MUSIC_APPS } from '@musicshare/shared';
import NowPlayingReader, { type NativePlayback } from '@/modules/now-playing-reader'; import NowPlayingReader, { type NativePlayback } from '@/modules/now-playing-reader';
import { api } from './api';
import { API_URL } from './config'; import { API_URL } from './config';
import { ensureDevice } from './device';
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). */ /** True on Android builds that include the native module (not iOS, web, or Expo Go). */
export const readerAvailable = NowPlayingReader !== null; export const readerAvailable = NowPlayingReader !== null;
@@ -20,57 +16,25 @@ const extraApps = String(process.env.EXPO_PUBLIC_EXTRA_MEDIA_APPS ?? '')
.filter(Boolean); .filter(Boolean);
const allowedApps = [...Object.keys(ANDROID_MUSIC_APPS), ...extraApps]; 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 * Make sure this phone is registered and the native listener knows where to
* report. Cheap to call repeatedly (app start, returning from settings). * 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> { export async function syncDevice(): Promise<void> {
syncing ??= doSyncDevice().finally(() => {
syncing = null;
});
return syncing;
}
async function doSyncDevice(): Promise<void> {
if (!NowPlayingReader || !NowPlayingReader.isPermissionGranted()) return; if (!NowPlayingReader || !NowPlayingReader.isPermissionGranted()) return;
let device = await storedDevice(); const device = await ensureDevice({ replace: NowPlayingReader.needsReregistration() });
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); NowPlayingReader.configure(API_URL, device.token, allowedApps);
} }
/** Sign-out: stop reporting and revoke this phone's token (while the session is still valid). */ /** Sign-out: stop the native listener reporting. */
export async function unregisterDevice(): Promise<void> { export const stopReader = () => NowPlayingReader?.clear();
NowPlayingReader?.clear();
const device = await storedDevice();
if (device) await api.removeDevice(device.id).catch(() => {});
await SecureStore.deleteItemAsync(DEVICE_KEY);
}
export const openReaderSettings = () => NowPlayingReader?.openPermissionSettings(); export const openReaderSettings = () => NowPlayingReader?.openPermissionSettings();
/** After resuming sharing, re-send what's playing so friends see it immediately. */ /** After resuming sharing, re-send what's playing so friends see it immediately. */
export const resendCurrentPlayback = () => NowPlayingReader?.resend(); export const resendCurrentPlayback = () => NowPlayingReader?.resend();
export async function shouldOfferSetup(): Promise<boolean> { export const readerGranted = () => NowPlayingReader?.isPermissionGranted() ?? false;
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. */ /** Live permission state and what the listener currently sees. Re-checks when the app returns to the foreground. */
export function useReader() { export function useReader() {
+3 -1
View File
@@ -2,7 +2,8 @@ import * as SecureStore from 'expo-secure-store';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared'; import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
import { api, setApiToken } from './api'; import { api, setApiToken } from './api';
import { unregisterDevice } from './nowPlayingReader'; import { unregisterDevice } from './device';
import { stopReader } from './nowPlayingReader';
const TOKEN_KEY = 'musicshare.session'; const TOKEN_KEY = 'musicshare.session';
@@ -60,6 +61,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
stopReader();
await unregisterDevice().catch(() => {}); await unregisterDevice().catch(() => {});
await api.logout().catch(() => {}); await api.logout().catch(() => {});
await SecureStore.deleteItemAsync(TOKEN_KEY); await SecureStore.deleteItemAsync(TOKEN_KEY);
+20
View File
@@ -0,0 +1,20 @@
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import { api } from './api';
import { appleMusicAvailable } from './appleMusic';
import { readerAvailable, readerGranted } from './nowPlayingReader';
const SETUP_SEEN_KEY = 'musicshare.shareSetupSeen';
/** First run: should we open the one-tap "share what you're playing" screen? */
export async function shouldOfferSetup(): Promise<boolean> {
if ((await SecureStore.getItemAsync(SETUP_SEEN_KEY)) !== null) return false;
if (Platform.OS === 'android') return readerAvailable && !readerGranted();
if (Platform.OS === 'ios' && appleMusicAvailable) {
const connections = await api.connections().catch(() => []);
return connections.length === 0;
}
return false;
}
export const markSetupSeen = () => SecureStore.setItemAsync(SETUP_SEEN_KEY, '1');