MusicKitとは

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

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

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.

今回作るアプリ

  • ライブラリの曲情報の一覧を取得して表示
  • オススメのアルバム情報の一覧を取得して表示
  • オススメのプレイリスト情報の一覧を取得して表示
  • オススメのステーション情報の一覧を取得して表示
  • ライブラリの曲情報の一覧のアートワークを押下するとその曲を再生できる
  • 再生中の曲を一時停止/再開できる

事前準備

1. App ServiceのMusicKitを有効化し、開発者トークンを自動生成するようにする

MusicKitを有効化することでApple Music APIにリクエストする際に必要な開発者トークンを自動生成してくれるようになる。

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


Identifiersの右側にあるプラスボタンをクリック。



App IDsを選択しContinueをクリック。


Appを選択しContinueをクリック。


Bundle IDとDescriptionを記入する。
MusicKitにチェックを入れる。
Continueをクリック。


プロジェクトのBundle IDを④で設定したIDと同値にする

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に準拠しているオブジェクトにはSongAlbumPlaylistなどがあり、それぞれMusicLibraryRequestに指定することができます。
今回は曲一覧を取得したいのでSongを指定します。

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()でアプリがユーザーの音楽情報にアクセスするためのリクエストを実行します。

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>にアクセスしてartworktitleartistNameを表示します。

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です。

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
        }
    }
}
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のみなのでPlaylistStationを表示したい場合は読み替えてください。

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に再生する音楽情報をリストで保持していて、ここに追加された音楽情報を順番に再生する仕組みです。

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()
    }
}
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()
    }
}
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)
    }
}