MusicKitを使ってiOSの音楽再生アプリを作ってみる

# MusicKitとは
MusicKitとはクライアントアプリとApple Musicを統合することができるライブラリで、
Apple Music内の音楽情報にアクセスすることができ、音楽の再生や停止が可能。

>Use MusicKit to integrate your app with Apple Music API, a web service you use to access information about music items in the Apple Music catalog. Using MusicKit, you can more easily build apps that tie into Apple Music.

https://developer.apple.com/documentation/musickit

# 今回作るアプリ
- ライブラリの曲情報の一覧を取得して表示
- オススメのアルバム情報の一覧を取得して表示
- オススメのプレイリスト情報の一覧を取得して表示
- オススメのステーション情報の一覧を取得して表示
- ライブラリの曲情報の一覧のアートワークを押下するとその曲を再生できる
- 再生中の曲を一時停止/再開できる
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3979045/bcee6e03-c7eb-d6dc-7fee-98f9f8bf4efe.png" width=30%>

# 事前準備
### 1. App ServiceのMusicKitを有効化し、開発者トークンを自動生成するようにする
MusicKitを有効化することでApple Music APIにリクエストする際に必要な開発者トークンを自動生成してくれるようになる。

https://developer.apple.com/documentation/musickit/using-automatic-token-generation-for-apple-music-api

①
Identifiersの右側にあるプラスボタンをクリック。
![スクリーンショット 2025-01-23 21.45.54.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3979045/ad7f4303-4c84-5d46-d8da-4d4f9c34c565.jpeg)


②
App IDsを選択しContinueをクリック。
![スクリーンショット 2025-01-23 21.46.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3979045/2e5959b4-173b-a5be-cde8-32cfa7d20668.png)


③
Appを選択しContinueをクリック。
![スクリーンショット 2025-01-23 21.46.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3979045/627b9035-54a6-0ef5-f08b-0f884a9acd32.png)


④
Bundle IDとDescriptionを記入する。
MusicKitにチェックを入れる。
Continueをクリック。
![Untitled design (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3979045/45b91e7f-b7c9-49a7-81ef-079ee06b6242.png)


⑤
プロジェクトのBundle IDを④で設定したIDと同値にする
![スクリーンショット 2025-02-10 21.50.22.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3979045/b6acd1d5-332a-c1f1-bae8-4b35e307ff69.jpeg)


### 2. クライアントアプリがユーザーの音楽メディアライブラリへのアクセスを要求する理由をユーザーに伝えるためのメッセージを設定する
info.plistに以下のkey-valueを追加する
| Name | Type |
|:-----------|:------------|
| Privacy - Media Library Usage Description | String |

```
<key>NSAppleMusicUsageDescription</key>
<string>楽曲再生のために音楽ライブラリにアクセスする必要があります。</string>
```

https://developer.apple.com/documentation/bundleresources/information-property-list/nsapplemusicusagedescription

# ここでは以下のAPIを使ってみようと思います
* **MusicLibraryRequest**
ユーザーのApple Musicのライブラリ情報(アルバムや曲やプレイリストなど)をリクエストする

https://developer.apple.com/documentation/musickit/musiclibrarysearchrequest
* **MusicPersonalRecommendationsRequest**
ユーザーのApple Musicのライブラリ情報や再生履歴を元にオススメの音楽をリクエストする

https://developer.apple.com/documentation/musickit/musicpersonalrecommendationsrequest

* **ApplicationMusicPlayer**
音楽を再生することができるオブジェクトで、他の音楽再生アプリの状態に影響を与えない。
音楽再生機能を持つもう一つのSystemMusicPlayerというオブジェクトがあるが、こちらは他の音楽再生アプリの状態に影響を与えるので、今回はApplicationMusicPlayerを利用する。

https://developer.apple.com/documentation/musickit/applicationmusicplayer

# MusicLibraryRequestを使ってApple Music内のライブラリにアクセスする
MusicKitのAPIを呼び出すモジュール。
`MusicLibraryRequest<MusicItemType>`の`response()`を呼び出すと`MusicLibraryResponse<MusicItemType>`が返却されます。
`MusicLibraryResponse<MusicItemType>`は`items`プロパティを持っていて、ここに音楽情報のコレクションである`MusicItemCollection<MusicItemType>`が格納されています。

`MusicItemType`に準拠しているオブジェクトには`Song`や`Album`や`Playlist`などがあり、それぞれ`MusicLibraryRequest`に指定することができます。
今回は曲一覧を取得したいので`Song`を指定します。
```swift
import Foundation
import MusicKit

protocol MusicService {
    func fetchSongs() async throws -> MusicItemCollection<Song>
}

class MusicServiceImpl: MusicService {
    func fetchSongs() async throws -> MusicItemCollection<Song> {
        do {
            let response = try await MusicLibraryRequest<Song>().response()
            return response.items
        } catch {
            handleError(error, context: "Fetching songs failed")
            throw error
        }
    }
}
```
MusicServiceのAPIを呼び出し、返却された楽曲情報を`songs: MusicItemCollection<Song>`変数に格納し、Viewに公開します。
MusicKitを使用して音楽情報にアクセスするには、最初に`MusicAuthorization.request()`でアプリがユーザーの音楽情報にアクセスするためのリクエストを実行します。
```swift
import Foundation
import MusicKit

class MusicViewModel: ObservableObject {
    private let musicService: MusicService
    
    @Published var songs: MusicItemCollection<Song> = []
    @Published var authorizationStatus: MusicAuthorization.Status = .notDetermined
    
    init(musicService: MusicService) {
        self.musicService = musicService
    }
    
    func authorize() async {
        let status = await MusicAuthorization.request()
        DispatchQueue.main.async { [self] in
            authorizationStatus = status
        }
    }
    
    func fetchSongs() async throws {
        guard authorizationStatus == .authorized else {
            print("not authorized")
            return
        }
        
        do {
            let result = try await musicService.fetchSongs()
            DispatchQueue.main.async {
                self.songs = result
            }
        } catch {
            print(error)
        }
    }
}

```
MusicViewModelで保持している`songs: MusicItemCollection<Song>`にアクセスして`artwork`や`title`や`artistName`を表示します。
```swift
import SwiftUI
import MusicKit

struct ContentView: View {
    @StateObject private var viewModel = MusicViewModel(musicService: MusicServiceImpl())
    
    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                
                Text("ライブラリの曲一覧を取得")
                    .font(.headline)
                ScrollView(.horizontal) {
                    LazyHStack(alignment: .top) {
                        if viewModel.songs.isEmpty {
                            Text("Empty Playlist")
                        } else {
                            ForEach(Array(viewModel.songs)) { song in
                                VStack(alignment: .leading) {
                                    if let artwork = song.artwork {
                                        ArtworkImage(artwork, width: 100, height: 100)
                                    } else {
                                        Image(systemName: "music.note")
                                            .frame(width: 100, height: 100, alignment: .leading)
                                    }
                                    VStack(alignment: .leading) {
                                        Text(song.title)
                                            .font(.headline)
                                            .frame(width: 100)
                                            .lineLimit(1)
                                        Text(song.artistName)
                                            .font(.caption)
                                    }
                                }
                                .padding(.horizontal, 5)
                            }
                        }
                    }
                    .padding()
                }
            }
            .onAppear() {
                Task {
                    await viewModel.authorize()
                    try await viewModel.fetchSongs()
                }
            }
        }
    }
}
```

# MusicPersonalRecommendationsRequestを使ってオススメの音楽情報をリクエストする
MusicPersonalRecommendationsRequestはユーザーのApple Musicのライブラリ情報や再生履歴を元にパーソナライズされたオススメの音楽をリクエストすることができるAPIです。

```swift
import Foundation
import MusicKit

protocol MusicService {
    func fetchRecommendations() async throws -> MusicItemCollection<MusicPersonalRecommendation>
}

class MusicServiceImpl: MusicService {
    func fetchRecommendations() async throws -> MusicItemCollection<MusicPersonalRecommendation> {
        do {
            let response = try await MusicPersonalRecommendationsRequest().response()
            return response.recommendations
        } catch {
            handleError(error, context: "Fetching recommendations failed")
            throw error
        }
    }
}
```

```swift
import Foundation
import MusicKit

class MusicViewModel: ObservableObject {
    private let musicService: MusicService
    
    @Published var authorizationStatus: MusicAuthorization.Status = .notDetermined
    @Published var recomendatedAlbums: [AlbumEnity] = []
    @Published var recomendatedPlaylists: [PlaylistEnity] = []
    @Published var recomendatedStations: [StationEnity] = []
    
    init(musicService: MusicService) {
        self.musicService = musicService
    }
    
    func authorize() async {
        let status = await MusicAuthorization.request()
        DispatchQueue.main.async { [self] in
            authorizationStatus = status
            handleAuthorizationStatus(status: status)
        }
    }
    
    func fetchRecomendations() async throws {
        guard authorizationStatus == .authorized else {
            print("not authorized")
            return
        }
        
        do {
            let recomendations = try await musicService.fetchRecommendations()
            for recomendation in recomendations {
                for item in recomendation.items {
                    switch item {
                    // MusicItemCollection<Album>のコレクションからAlbumを抽出
                    case .album(let album): do {
                        self.recomendatedAlbums.append(
                            AlbumEnity(
                                id: album.id,
                                title: album.title,
                                artistName: album.artistName,
                                artwork: album.artwork
                            )
                        )
                    }
                    // MusicItemCollection<Playlist>のコレクションからPlaylistを抽出
                    case .playlist(let playlist): do {
                        self.recomendatedPlaylists.append(
                            PlaylistEnity(
                                id: playlist.id,
                                title: playlist.name,
                                artwork: playlist.artwork
                            )
                        )
                    }
                    // MusicItemCollection<Station>のコレクションからStationを抽出
                    case .station(let station): do {
                        self.recomendatedStations.append(
                            StationEnity(
                                id: station.id,
                                title: station.name,
                                artwork: station.artwork
                            )
                        )
                    }
                    @unknown default:
                        return
                    }
                }
            }
        }
    }
}

struct AlbumEnity: Identifiable {
    var id: MusicItemID
    var title: String
    var artistName: String
    var artwork: Artwork?
}

struct PlaylistEnity: Identifiable {
    var id: MusicItemID
    var title: String
    var artwork: Artwork?
}

struct StationEnity: Identifiable {
    var id: MusicItemID
    var title: String
    var artwork: Artwork?
}
```
以下のUIコードは`Album`のみなので`Playlist`や`Station`を表示したい場合は読み替えてください。
```swift
import SwiftUI
import MusicKit

struct ContentView: View {
    @StateObject private var viewModel = MusicViewModel(musicService: MusicServiceImpl())
    
    var body: some View {
        ZStack {
            ScrollView {
                VStack(alignment: .leading) {
                    Text("オススメのアルバム一覧を取得")
                        .font(.headline)
                    ScrollView(.horizontal) {
                        LazyHStack(alignment: .top) {
                            if viewModel.recomendatedAlbums.isEmpty {
                                Text("Empty Playlist")
                            } else {
                                ForEach(viewModel.recomendatedAlbums) { recommendation in
                                    VStack(alignment: .leading) {
                                        if let artwork = recommendation.artwork {
                                            ArtworkImage(artwork, width: 100, height: 100)
                                        } else {
                                            Image(systemName: "music.note")
                                                .frame(width: 100, height: 100, alignment: .leading)
                                        }
                                        VStack(alignment: .leading) {
                                            Text(recommendation.title)
                                                .font(.headline)
                                                .frame(width: 100)
                                                .lineLimit(1)
                                        }
                                    }
                                    .padding(.horizontal, 5)
                                }
                            }
                        }
                        .padding()
                    }
                }
                .onAppear() {
                    Task {
                        await viewModel.authorize()
                        try await viewModel.fetchRecomendations()
                    }
                }
            }
        }
    }
}
```

# ApplicationMusicPlayerを使って音楽を再生する
`ApplicationMusicPlayer.shared.queue`に再生する音楽情報をリストで保持していて、ここに追加された音楽情報を順番に再生する仕組みです。
```swift
import Foundation
import MusicKit

protocol MusicService {
    func playback(song: Song) async throws
    func restartPlayback() async throws
    func pause()
}

class MusicServiceImpl: MusicService {
    let player = ApplicationMusicPlayer.shared
    
    func playback(song: Song) async throws {
        do {
            player.queue = [song]
            try await player.play()
        } catch {
            handleError(error, context: "Playing song '\(song.title)' failed")
            throw error
        }
    }
    
    func restartPlayback() async throws{
        do {
            try await player.play()
        } catch {
            handleError(error, context: "restart song failed")
            throw error
        }
    }
    
    func pause() {
        player.pause()
    }
}
```

```swift
import Foundation
import MusicKit

class MusicViewModel: ObservableObject {
    private let musicService: MusicService
    
    @Published var isPlaying: Bool = false
    @Published var playingSong: Song?
    
    init(musicService: MusicService) {
        self.musicService = musicService
    }
    
    func playback(song: Song) async throws{
        DispatchQueue.main.async {
            self.isPlaying = true
            self.playingSong = song
        }
        try await musicService.playback(song: song)
    }
    
    func restartPlayback() async throws{
        DispatchQueue.main.async {
            self.isPlaying = true
        }
        try await musicService.restartPlayback()
    }
    
    func pause() {
        isPlaying = false
        musicService.pause()
    }
}
```

```swift
import SwiftUI
import MusicKit

struct ContentView: View {
    @StateObject private var viewModel = MusicViewModel(musicService: MusicServiceImpl())
    
    var body: some View {
        ZStack {
            ScrollView {
                VStack(alignment: .leading) {
                    // MusicLibraryRequestのレスポンスから取得した音楽情報を表示して、タップした契機で再生処理を発火させる
                    Text("ライブラリの曲一覧を取得")
                        .font(.headline)
                    ScrollView(.horizontal) {
                        LazyHStack(alignment: .top) {
                            if viewModel.songs.isEmpty {
                                Text("Empty Playlist")
                            } else {
                                ForEach(Array(viewModel.songs)) { song in
                                    VStack(alignment: .leading) {
                                        if let artwork = song.artwork {
                                            ArtworkImage(artwork, width: 100, height: 100)
                                        } else {
                                            Image(systemName: "music.note")
                                                .frame(width: 100, height: 100, alignment: .leading)
                                        }
                                        VStack(alignment: .leading) {
                                            Text(song.title)
                                                .font(.headline)
                                                .frame(width: 100)
                                                .lineLimit(1)
                                            Text(song.artistName)
                                                .font(.caption)
                                        }
                                    }
                                    .padding(.horizontal, 5)
                                    .onTapGesture {
                                        Task {
                                            // 音楽再生処理を開始
                                            try await viewModel.playback(song: song)
                                        }
                                    }
                                }
                            }
                        }
                        .padding()
                    }
                }
                .onAppear() {
                    Task {
                        await viewModel.authorize()
                        try await viewModel.fetchSongs()
                    }
                }
            }
        }

        // 再生と一時停止を制御する
        PlayingBar(
            playingSong: $viewModel.playingSong,
            isPlaying: $viewModel.isPlaying,
            restartPlayback: { try await viewModel.restartPlayback() },
            pausePlayback: { viewModel.pause() }
        )
    }
}

struct PlayingBar: View {
    @Binding var playingSong: Song?
    @Binding var isPlaying: Bool
    
    var restartPlayback: () async throws -> Void
    var pausePlayback: () -> Void
    
    var body: some View {
        ZStack {
            Color.green
            HStack {
                Text(playingSong?.title ?? "再生中の曲はありません")
                Spacer()
                
                Image(systemName: isPlaying ? "pause.fill" : "play.fill")
                    .onTapGesture {
                        if isPlaying {
                            pausePlayback()
                        } else {
                            guard let _ = playingSong else { return }
                            Task {
                                try await restartPlayback()
                            }
                        }
                    }
            }
            .padding()
            .background(Color.green)
        }
        .frame(height: 70)
    }
}
```