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:
@@ -9,7 +9,10 @@
|
||||
"userInterfaceStyle": "dark",
|
||||
"ios": {
|
||||
"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": {
|
||||
"adaptiveIcon": {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["AppleMusicModule"]
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -3,13 +3,15 @@ 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 { sortFeed, type ConnectionDTO, 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 { appleMusicAvailable } from '@/lib/appleMusic';
|
||||
import { useReader } from '@/lib/nowPlayingReader';
|
||||
import { shouldOfferSetup } from '@/lib/setup';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
@@ -22,6 +24,8 @@ export default function FeedScreen() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
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).
|
||||
useEffect(() => {
|
||||
@@ -74,6 +78,12 @@ export default function FeedScreen() {
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<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 && (
|
||||
<Card>
|
||||
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
|
||||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { useSession } from '@/lib/session';
|
||||
@@ -127,18 +128,54 @@ function Connections() {
|
||||
await mutate();
|
||||
};
|
||||
|
||||
const connectOAuth = (provider: 'spotify' | 'plex') =>
|
||||
run(async () => {
|
||||
/** 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') {
|
||||
if (result.type !== 'success') return false;
|
||||
const error = Linking.parse(result.url).queryParams?.error;
|
||||
if (typeof error === 'string') Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, 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 () => {
|
||||
if (!byProvider.has('lastfm') && !(await signIn('lastfm'))) return;
|
||||
Alert.alert('Last step: link Spotify', 'On the next page, tap Connect next to "Spotify Scrobbling" and approve.', [
|
||||
{ text: 'Open Last.fm', onPress: linkSpotifyOnLastfm },
|
||||
]);
|
||||
});
|
||||
|
||||
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) =>
|
||||
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
@@ -159,6 +196,16 @@ function Connections() {
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<Heading>Music services</Heading>
|
||||
@@ -166,6 +213,20 @@ function Connections() {
|
||||
Connect where you listen. Friends see what you're playing in real time.
|
||||
</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) => (
|
||||
<View key={p} style={[styles.connectionColumn, styles.connection]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -180,16 +241,30 @@ function Connections() {
|
||||
</View>
|
||||
))}
|
||||
|
||||
{(['lastfm', 'listenbrainz'] as const).map((p) => (
|
||||
<View style={styles.connectionColumn}>
|
||||
<View style={styles.connection}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider}>Last.fm</Text>
|
||||
{status('lastfm', 'Also the way to share Spotify for everyone (via Spotify’s Last.fm link).')}
|
||||
</View>
|
||||
{byProvider.has('lastfm') ? (
|
||||
<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
|
||||
key={p}
|
||||
provider={p}
|
||||
status={status(p, p === 'lastfm' ? 'Anything that scrobbles: YouTube Music, Tidal, Deezer…' : 'Open-source scrobbling')}
|
||||
active={byProvider.has(p)}
|
||||
onConnect={(username) => run(() => api.connectUsername(p, username))}
|
||||
onDisconnect={() => disconnect(p)}
|
||||
provider="listenbrainz"
|
||||
status={status('listenbrainz', 'Open-source scrobbling')}
|
||||
active={byProvider.has('listenbrainz')}
|
||||
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
|
||||
onDisconnect={() => disconnect('listenbrainz')}
|
||||
/>
|
||||
))}
|
||||
|
||||
<JellyfinConnection
|
||||
status={status('jellyfin', 'Sign in to your Jellyfin server. Your password isn’t stored.')}
|
||||
|
||||
@@ -3,9 +3,11 @@ import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
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 { api } from '@/lib/api';
|
||||
import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
|
||||
import { syncDevice } from '@/lib/nowPlayingReader';
|
||||
import { SessionProvider, useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
@@ -65,6 +67,7 @@ function AppNavigator() {
|
||||
return () => sub.remove();
|
||||
},
|
||||
}}>
|
||||
{signedIn && appleMusicAvailable && <AppleMusicLive />}
|
||||
<Stack
|
||||
screenOptions={{
|
||||
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({
|
||||
offline: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: space.xl, gap: space.md },
|
||||
});
|
||||
|
||||
@@ -1,24 +1,83 @@
|
||||
import { router } from 'expo-router';
|
||||
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 { Body, Button, Card, Title } from '@/components/ui';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
|
||||
|
||||
export default function ShareSetupScreen() {
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (!granted) return;
|
||||
@@ -27,8 +86,6 @@ export default function ShareSetupScreen() {
|
||||
.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 }}>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
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';
|
||||
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). */
|
||||
export const readerAvailable = NowPlayingReader !== null;
|
||||
@@ -20,57 +16,25 @@ const extraApps = String(process.env.EXPO_PUBLIC_EXTRA_MEDIA_APPS ?? '')
|
||||
.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> {
|
||||
export async function syncDevice(): 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));
|
||||
}
|
||||
const device = await ensureDevice({ replace: NowPlayingReader.needsReregistration() });
|
||||
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);
|
||||
}
|
||||
/** Sign-out: stop the native listener reporting. */
|
||||
export const stopReader = () => NowPlayingReader?.clear();
|
||||
|
||||
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');
|
||||
export const readerGranted = () => NowPlayingReader?.isPermissionGranted() ?? false;
|
||||
|
||||
/** Live permission state and what the listener currently sees. Re-checks when the app returns to the foreground. */
|
||||
export function useReader() {
|
||||
|
||||
@@ -2,7 +2,8 @@ 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';
|
||||
import { unregisterDevice } from './device';
|
||||
import { stopReader } from './nowPlayingReader';
|
||||
|
||||
const TOKEN_KEY = 'musicshare.session';
|
||||
|
||||
@@ -60,6 +61,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
stopReader();
|
||||
await unregisterDevice().catch(() => {});
|
||||
await api.logout().catch(() => {});
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user