diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 87e81df..2c4421b 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -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": { diff --git a/apps/mobile/modules/apple-music/expo-module.config.json b/apps/mobile/modules/apple-music/expo-module.config.json new file mode 100644 index 0000000..642d026 --- /dev/null +++ b/apps/mobile/modules/apple-music/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["AppleMusicModule"] + } +} diff --git a/apps/mobile/modules/apple-music/index.ts b/apps/mobile/modules/apple-music/index.ts new file mode 100644 index 0000000..d19b0d5 --- /dev/null +++ b/apps/mobile/modules/apple-music/index.ts @@ -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'; diff --git a/apps/mobile/modules/apple-music/ios/AppleMusic.podspec b/apps/mobile/modules/apple-music/ios/AppleMusic.podspec new file mode 100644 index 0000000..847921b --- /dev/null +++ b/apps/mobile/modules/apple-music/ios/AppleMusic.podspec @@ -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 diff --git a/apps/mobile/modules/apple-music/ios/AppleMusicModule.swift b/apps/mobile/modules/apple-music/ios/AppleMusicModule.swift new file mode 100644 index 0000000..164d0ec --- /dev/null +++ b/apps/mobile/modules/apple-music/ios/AppleMusicModule.swift @@ -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" + } + } +} diff --git a/apps/mobile/modules/apple-music/src/AppleMusic.types.ts b/apps/mobile/modules/apple-music/src/AppleMusic.types.ts new file mode 100644 index 0000000..a8c2027 --- /dev/null +++ b/apps/mobile/modules/apple-music/src/AppleMusic.types.ts @@ -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; +}; diff --git a/apps/mobile/modules/apple-music/src/AppleMusicModule.ts b/apps/mobile/modules/apple-music/src/AppleMusicModule.ts new file mode 100644 index 0000000..2bf4cfa --- /dev/null +++ b/apps/mobile/modules/apple-music/src/AppleMusicModule.ts @@ -0,0 +1,13 @@ +import { NativeModule, requireOptionalNativeModule } from 'expo'; + +import type { AppleMusicAuthorization, AppleMusicModuleEvents, MusicAppPlayback } from './AppleMusic.types'; + +declare class AppleMusicModule extends NativeModule { + authorizationStatus(): AppleMusicAuthorization; + requestAuthorization(): Promise; + getUserToken(developerToken: string): Promise; + getNowPlaying(): Promise; +} + +/** iOS only. null on Android, web and Expo Go, so callers must feature-check. */ +export default requireOptionalNativeModule('AppleMusic'); diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index f58b26d..5e2c4c5 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -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(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={ + {offerAppleMusic && ( + + Connect Apple Music so friends see what you play. One tap. +