Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add playback queue and library tracks #4

Merged
merged 4 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 54 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -43,6 +49,7 @@ npx pod-install
<string>Allow to continue</string>
```
- 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

Expand Down Expand Up @@ -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) {
Expand All @@ -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:

Expand Down
4 changes: 4 additions & 0 deletions ios/MusicModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ @interface RCT_EXTERN_MODULE(MusicModule, NSObject)

RCT_EXTERN_METHOD(catalogSearch:(NSString *)term types:(NSArray<NSString *> *)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)
Expand Down
240 changes: 236 additions & 4 deletions ios/MusicModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class MusicModule: RCTEventEmitter {
return
}


switch currentEntry.item {
case .song(let song):
Task {
Expand Down Expand Up @@ -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<MusicVideo>(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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Song>)
case album(MusicCatalogResourceRequest<Album>)
case playlist(MusicCatalogResourceRequest<Playlist>)
case station(MusicCatalogResourceRequest<Station>)

static func getRequest(forType type: String, musicItemId: MusicItemID) -> MediaType? {
switch type {
case "song":
return .song(MusicCatalogResourceRequest<Song>(matching: \.id, equalTo: musicItemId))
case "album":
return .album(MusicCatalogResourceRequest<Album>(matching: \.id, equalTo: musicItemId))
case "playlist":
return .playlist(MusicCatalogResourceRequest<Playlist>(matching: \.id, equalTo: musicItemId))
case "station":
return .station(MusicCatalogResourceRequest<Station>(matching: \.id, equalTo: musicItemId))
default:
return nil
}
}
}
}
Loading