diff --git a/README.md b/README.md index fbd8e8c..a43e1ba 100644 --- a/README.md +++ b/README.md @@ -10,27 +10,33 @@ An [Example](./example) project was developed to exercise and test all functiona The following table shows the platform support for various Apple Music functionality within this library. -| Feature | iOS | -| :--------------------------- | :-: | -| **Auth** | -| `authorize` | ✅ | -| `checkSubscription` | ✅ | -| **Player** | -| `play` | ✅ | -| `pause` | ✅ | -| `togglePlayerState` | ✅ | -| `skipToNextEntry` | ✅ | -| `getCurrentState` | ✅ | -| `addListener` | ✅ | -| `removeListener` | ✅ | -| **MusicKit** | -| `catalogSearch` | ✅ | +| Feature | iOS | +|:-----------------------| :-: | +| **Auth** | +| `authorize` | ✅ | +| `checkSubscription` | ✅ | +| **Player** | +| `play` | ✅ | +| `pause` | ✅ | +| `togglePlayerState` | ✅ | +| `skipToNextEntry` | ✅ | +| `getCurrentState` | ✅ | +| `addListener` | ✅ | +| `removeListener` | ✅ | +| **MusicKit** | +| `catalogSearch` | ✅ | +| `getTracksFromLibrary` | ✅ | +| `setPlaybackQueue` | ✅ | ## Installation ```sh npm install @lomray/react-native-apple-music ``` +- In your Podfile, set minimum IOS target for Pod installation: +```sh +platform :ios, 15.0 +``` ```sh npx pod-install ``` @@ -43,6 +49,7 @@ npx pod-install Allow to continue ``` - Ensure that your Apple Developer account has the MusicKit entitlement enabled and the appropriate MusicKit capabilities are set in your app's bundle identifier. +- It's highly recommended to set a minimum deployment target of your application to 16.0 and above, because MusicKit requires it to perform a catalog search and some convertation methods. ## Linking @@ -139,7 +146,7 @@ Search the Apple Music catalog: ```javascript async function searchCatalog(query) { try { - const types = ['songs', 'albums']; // Define the types of items you're searching for + const types = ['songs', 'albums']; // Define the types of items you're searching for. The result will contain items among songs/albums const results = await MusicKit.catalogSearch(query, types); console.log('Search Results:', results); } catch (error) { @@ -148,6 +155,37 @@ async function searchCatalog(query) { } ``` + +### Getting user's recently played songs or albums +Get a list of recently played items: + +```javascript +async function getTracksFromLibrary() { + try { + const results = await MusicKit.getTracksFromLibrary(); + + console.log('User`s library Results:', results); + } catch (error) { + console.error('Getting user tracks failed:', error); + } +} +``` + + + +### Set a playback Queue +Load a system Player with Song, Album, Playlist or Station, using their ID: + +```javascript +async function setPlaybackQueue() { + try { + await MusicKit.setPlaybackQueue("123456", "album"); + } catch (error) { + console.error('Setting playback queue:', error); + } +} +``` + ### Using Hooks The package provides hooks for reactive states in your components: diff --git a/ios/MusicModule.m b/ios/MusicModule.m index bcea500..33312d7 100644 --- a/ios/MusicModule.m +++ b/ios/MusicModule.m @@ -10,6 +10,10 @@ @interface RCT_EXTERN_MODULE(MusicModule, NSObject) RCT_EXTERN_METHOD(catalogSearch:(NSString *)term types:(NSArray *)types options:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(setPlaybackQueue:(NSString *)itemId type:(NSString *)type resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getTracksFromLibrary:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(play) RCT_EXTERN_METHOD(pause) RCT_EXTERN_METHOD(skipToNextEntry) diff --git a/ios/MusicModule.swift b/ios/MusicModule.swift index 636bfd4..76f6734 100644 --- a/ios/MusicModule.swift +++ b/ios/MusicModule.swift @@ -102,6 +102,7 @@ class MusicModule: RCTEventEmitter { return } + switch currentEntry.item { case .song(let song): Task { @@ -129,7 +130,40 @@ class MusicModule: RCTEventEmitter { } case .musicVideo(let musicVideo): - print("The current item is a music video: \(musicVideo.title)") + Task { + print("The current item is a music video: \(musicVideo.title)") + + let request = MusicCatalogResourceRequest(matching: \.id, equalTo: musicVideo.id) + do { + let response = try await request.response() + if let foundMusicVideo = response.items.first { + if #available(iOS 16.0, *) { + let songInfo = self.convertMusicVideosToDictionary(foundMusicVideo) + DispatchQueue.main.async { + completion(songInfo) + } + } else { + print("Update your IOS version to 16.0>") + DispatchQueue.main.async { + completion(nil) + } + } + } else { + print("Music video not found in the response.") + DispatchQueue.main.async { + completion(nil) + } + } + } catch { + print("Error requesting music video: \(error)") + DispatchQueue.main.async { + completion(nil) + } + } + } + + case .some(let some): + print("The current item is some item:\(some.id)") completion(nil) default: @@ -173,10 +207,12 @@ class MusicModule: RCTEventEmitter { let canPlayCatalogContent = capabilities.contains(.musicCatalogPlayback) let hasCloudLibraryEnabled = capabilities.contains(.addToCloudMusicLibrary) + let isMusicCatalogSubscriptionEligible = capabilities.contains(.musicCatalogSubscriptionEligible) let subscriptionDetails: [String: Any] = [ "canPlayCatalogContent": canPlayCatalogContent, - "hasCloudLibraryEnabled": hasCloudLibraryEnabled + "hasCloudLibraryEnabled": hasCloudLibraryEnabled, + "isMusicCatalogSubscriptionEligible": isMusicCatalogSubscriptionEligible ] resolve(subscriptionDetails) @@ -276,6 +312,77 @@ class MusicModule: RCTEventEmitter { ] } + func convertAlbumToDictionary(_ album: Album) -> [String: Any] { + var artworkUrlString: String = "" + + if let artwork = album.artwork { + let artworkUrl = artwork.url(width: 200, height: 200) + + if let url = artworkUrl, url.scheme == "musicKit" { + print("Artwork URL is a MusicKit URL, may not be directly accessible: \(url)") + } else { + artworkUrlString = artworkUrl?.absoluteString ?? "" + } + } + + return [ + "id": String(describing: album.id), + "title": album.title, + "artistName": album.artistName, + "artworkUrl": artworkUrlString, + "trackCount": String(album.trackCount) + ] + } + + @available(iOS 16.0, *) + func convertMusicItemsToDictionary(_ track: RecentlyPlayedMusicItem) -> [String: Any] { + var resultCollection: [String: Any] = [ + "id": String(describing: track.id), + "title": track.title, + "subtitle": String(describing: track.subtitle ?? "") + ] + + switch track { + case .album: + resultCollection["type"] = "album" + break + case .playlist: + resultCollection["type"] = "playlist" + break + case .station: + resultCollection["type"] = "station" + break + default: + resultCollection["type"] = "unknown" + } + + return resultCollection + } + + @available(iOS 16.0, *) + func convertMusicVideosToDictionary(_ musicVideo: MusicVideo) -> [String: Any] { + var artworkUrlString: String = "" + + if let artwork = musicVideo.artwork { + let artworkUrl = artwork.url(width: 200, height: 200) + + if let url = artworkUrl, url.scheme == "musicKit" { + print("Artwork URL is a MusicKit URL, may not be directly accessible: \(url)") + } else { + artworkUrlString = artworkUrl?.absoluteString ?? "" + } + } + + return [ + "id": String(describing: musicVideo.id), + "title": musicVideo.title, + "artistName": musicVideo.artistName, + "artworkUrl": artworkUrlString, + "duration": musicVideo.duration! + ] + } + + @objc(catalogSearch:types:options:resolver:rejecter:) func catalogSearch(_ term: String, types: [String], options: NSDictionary, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { Task { @@ -302,10 +409,135 @@ class MusicModule: RCTEventEmitter { print("Response received: \(response)") let songs = response.songs.compactMap(convertSongToDictionary) - resolve(["results": songs]) + let albums = response.albums.compactMap(convertAlbumToDictionary) + + resolve(["songs": songs, "albums": albums]) } catch { reject("ERROR", "Failed to perform catalog search: \(error)", error) } } - } +} + + @available(iOS 16.0, *) + @objc(getTracksFromLibrary:rejecter:) + func getTracksFromLibrary(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { + Task { + do { + let request = MusicRecentlyPlayedContainerRequest() + let response = try await request.response() + + let tracks = response.items.compactMap(convertMusicItemsToDictionary) + + resolve(["recentlyPlayedItems": tracks]) + } catch { + reject("ERROR", "Failed to get recently played tracks: \(error)", error) + } + } + } + + @objc(setPlaybackQueue:type:resolver:rejecter:) + func setPlaybackQueue(_ itemId: String, type: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { + Task { + do { + let musicItemId = MusicItemID.init(itemId) + + if let requestType = MediaType.getRequest(forType: type, musicItemId: musicItemId) { + switch requestType { + case .song(let request): + // Use request for song type + let response = try await request.response() + + guard let tracksToBeAdded = response.items.first else { return } + + let player = SystemMusicPlayer.shared + + player.queue = [tracksToBeAdded] /// <- directly add items to the queue + + try await player.prepareToPlay() + + resolve("Track(s) are added to queue") + + return + + case .album(let request): + // Use request for album type + let response = try await request.response() + + guard let tracksToBeAdded = response.items.first else { return } + + let player = SystemMusicPlayer.shared + + player.queue = [tracksToBeAdded] /// <- directly add items to the queue + + try await player.prepareToPlay() + + resolve("Album is added to queue") + + return + + case .playlist(let request): + // Use request for playlist type + let response = try await request.response() + + guard let tracksToBeAdded = response.items.first else { return } + + let player = SystemMusicPlayer.shared + + player.queue = [tracksToBeAdded] /// <- directly add items to the queue + + try await player.prepareToPlay() + + resolve("Playlist is added to queue") + + return + + case .station(let request): + // Use request for station type + let response = try await request.response() + + guard let tracksToBeAdded = response.items.first else { return } + + let player = SystemMusicPlayer.shared + + player.queue = [tracksToBeAdded] /// <- directly add items to the queue + + try await player.prepareToPlay() + + resolve("Station is added to queue") + + return + + } + } else { + print("Unknown media type.") + + return + } + } catch { + reject("ERROR", "Failed to set tracks to queue: \(error)", error) + } + } + } + + enum MediaType { + case song(MusicCatalogResourceRequest) + case album(MusicCatalogResourceRequest) + case playlist(MusicCatalogResourceRequest) + case station(MusicCatalogResourceRequest) + + static func getRequest(forType type: String, musicItemId: MusicItemID) -> MediaType? { + switch type { + case "song": + return .song(MusicCatalogResourceRequest(matching: \.id, equalTo: musicItemId)) + case "album": + return .album(MusicCatalogResourceRequest(matching: \.id, equalTo: musicItemId)) + case "playlist": + return .playlist(MusicCatalogResourceRequest(matching: \.id, equalTo: musicItemId)) + case "station": + return .station(MusicCatalogResourceRequest(matching: \.id, equalTo: musicItemId)) + default: + return nil + } + } + } } diff --git a/src/index.ts b/src/index.ts index 8fbc921..70129ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,10 @@ export * from './types/auth-status'; export * from './types/check-subscription'; +export * from './types/music-item'; + +export * from './types/tracks-from-library'; + import useCurrentSong from './hooks/use-current-song'; import useIsPlaying from './hooks/use-is-playing'; import Auth from './modules/auth'; diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 407ad83..5883cc1 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -32,11 +32,12 @@ class Auth { return result; } catch (error) { - console.warn('Apple Music Kit: Check subscription failed.', error); + console.error('Apple Music Kit: Check subscription failed.', error); return { canPlayCatalogContent: false, hasCloudLibraryEnabled: false, + isMusicCatalogSubscriptionEligible: false, }; } } diff --git a/src/modules/music-kit.ts b/src/modules/music-kit.ts index 6b2b0c0..755e065 100644 --- a/src/modules/music-kit.ts +++ b/src/modules/music-kit.ts @@ -1,6 +1,8 @@ import { NativeModules } from 'react-native'; import type { CatalogSearchType } from '../types/catalog-search-type'; +import type { MusicItem } from '../types/music-item'; import type { ISong } from '../types/song'; +import type { ITracksFromLibrary } from '../types/tracks-from-library'; const { MusicModule } = NativeModules; @@ -13,7 +15,7 @@ class MusicKit { /** * Searches the Apple Music catalog using the specified search terms, types, and options. * @param {string} search - The search query string. - * @param {ICatalogSearchType[]} types - The types of catalog items to search for. + * @param {CatalogSearchType[]} types - The types of catalog items to search for. * @param {IEndlessListOptions} [options] - Additional options for the search. * @returns {Promise} A promise that resolves to the search results. */ @@ -30,6 +32,37 @@ class MusicKit { return []; } + + /** + * @param itemId - ID of collection to be set in a player's queue + * @param {MusicItem} type - Type of collection to be found and set + * @returns {Promise} A promise is resolved when tracks successfully added to a queue + */ + public static async setPlaybackQueue(itemId: string, type: MusicItem): Promise { + try { + await MusicModule.setPlaybackQueue(itemId, type); + } catch (error) { + console.error('Apple Music Kit: Setting Playback Failed.', error); + } + } + + /** + * Get a list of recently played items in user's library + * @return {Promise} A promise returns a list of recently played items including tracks, playlists, stations, albums + */ + public static async getTracksFromLibrary(): Promise { + try { + const result = await MusicModule.getTracksFromLibrary(); + + return result as ITracksFromLibrary; + } catch (error) { + console.error('Apple Music Kit: Getting tracks from user library failed.', error); + + return { + recentlyPlayedItems: [], + }; + } + } } export default MusicKit; diff --git a/src/types/check-subscription.ts b/src/types/check-subscription.ts index a494278..6dd6983 100644 --- a/src/types/check-subscription.ts +++ b/src/types/check-subscription.ts @@ -1,4 +1,14 @@ export interface ICheckSubscription { + /** + * The device allows playback of Apple Music catalog tracks + */ canPlayCatalogContent: boolean; + /** + * The device allows tracks to be added in user's library + */ hasCloudLibraryEnabled: boolean; + /** + * The device allows subscription to the Apple Music Catalog + */ + isMusicCatalogSubscriptionEligible: boolean; } diff --git a/src/types/music-item.ts b/src/types/music-item.ts new file mode 100644 index 0000000..73ed311 --- /dev/null +++ b/src/types/music-item.ts @@ -0,0 +1,6 @@ +export enum MusicItem { + SONG = 'song', + ALBUM = 'album', + PLAYLIST = 'playlist', + STATION = 'station', +} diff --git a/src/types/tracks-from-library.ts b/src/types/tracks-from-library.ts new file mode 100644 index 0000000..4261ae5 --- /dev/null +++ b/src/types/tracks-from-library.ts @@ -0,0 +1,12 @@ +import type { MusicItem } from './music-item'; + +export interface IUserTrack { + id: number; + title: string; + subtitle: string; + type: MusicItem; +} + +export interface ITracksFromLibrary { + recentlyPlayedItems: IUserTrack[]; +}