결과물
-MusicKit 설정
1. 사용하기 위해서 우선 Apple Developer 계정이 등록된 상태로!!
Certificates, Identifiers & Profiles <- MusicKit을 등록해주자
2. 다음은 Project -> TARGETS -> Info(Custom iOS Target Properties)에서
Privacy - Media Library Usage Description을 등록해주자
이 두 과정이 끝나면 MusicKit 맛대로 사용하면 된다!
-MusicKit 사용법
1. 필요한 기능을 하기위한 모델을 만들자
import Foundation
struct MusicModel: Codable, Hashable, Identifiable {
var id: String
var album: String?
var title: String
var artist: String
var artwork: URL?
var previewURL: URL?
}
extension MusicModel {
func toEntity() -> MusicEntity {
.init(
id: id,
album: album,
title: title,
artist: artist,
artwork: artwork?.absoluteString,
previewURL: previewURL?.absoluteString
)
}
}
음악의 앨범, 제목, 작곡가, 앨범이미지, 30초 미리듣기의 기능들을 수행해 보도록 모델을 구성했다.
2. MVVM 디자인 패턴으로 원하는 음악을 MusicKit으로 부터 fetch 해오는 과정을 Manager로 분류하자!
import Foundation
import Combine
import MusicKit
protocol MusicMangerProtocol {
func fetchMusic(with term: String) -> AnyPublisher<[MusicModel], Error>
}
class MusicManger: MusicMangerProtocol {
static let shared = MusicManger()
init() {}
func setupMusic() { // 앱 시작되자마자 MusicKit 검증해서 버벅임 없애기! 다른 방법있으면 교체 가능!
Task {
checkMusicAuthorizationStatus()
await performInitialMusicSearchIfNeeded()
}
}
// Apple Music 권한 상태를 확인하고, 필요한 경우 권한을 요청
private func checkMusicAuthorizationStatus() {
Task {
let status = await MusicAuthorization.request()
switch status {
case .authorized:
print("MusicAuthorization: Authorized")
// 권한이 승인된 경우 초기 음악 검색을 수행
await performInitialMusicSearchIfNeeded()
case .denied, .restricted:
print("MusicAuthorization: Denied or Restricted")
// 권한이 거부되거나 제한된 경우
case .notDetermined:
print("MusicAuthorization: Not Determined")
// 권한 요청을 아직 수행하지 않은 경우
@unknown default:
print("MusicAuthorization: Unknown")
}
}
}
// 필요한 경우 초기 음악 검색을 수행하는 함수: 검증 할때 버벅이기때문에 앱 시작시 강제 검색해서 버벅임을 풂
private func performInitialMusicSearchIfNeeded() async {
do {
var searchRequest = MusicCatalogSearchRequest(term: "Apple", types: [Song.self])
searchRequest.limit = 1
let response = try await searchRequest.response()
print("Initial music search performed with \(response.songs.count) results.")
} catch {
print("Error performing initial music search: \(error.localizedDescription)")
}
}
func fetchMusic(with term: String) -> AnyPublisher<[MusicModel], Error> {
Future<[MusicModel], Error> { promise in
Task {
let status = await MusicAuthorization.request()
switch status {
case .authorized:
do {
var request = MusicCatalogSearchRequest(
term: term,
types: [Song.self]
)
request.limit = 15
let response = try await request.response()
let models = response.songs.compactMap { song in
// if let artworkURL = song.artwork?.url(width: 200, height: 200) {
// print("\(artworkURL)")
// }
// return
MusicModel(
id: song.id.rawValue,
album: song.albumTitle,
title: song.title,
artist: song.artistName,
artwork: song.artwork?.url(width: 200, height: 200),
previewURL: song.previewAssets?.first?.url
)
}
promise(.success(models))
} catch {
promise(.failure(error))
}
default:
promise(.failure(ManagerError.unauthorized))
}
}
}
.eraseToAnyPublisher()
}
}
3. 자 그 다음 화면에서 보여질 기능들을 ViewModel에서 관리하자!! 음악재생하기, 음악 정지하기, 미리듣기 타임ProgressView 구성하기 기능을 담아봤다.
import Foundation
import Combine
import AVKit
class MusicViewModel: ObservableObject {
@Published var songs: [MusicModel] = []
@Published var searchText: String = ""
@Published var playbackProgress: Float = 0.0
@Published var isLoading = false // 음악 검색중인지 여부 나타내는 상태 변수임
@Published var isPlaying = false
private var player: AVPlayer?
var currentlyPlayingURL: URL?
private var timeObserverToken: Any?
private var musicManager: MusicMangerProtocol
private var cancellables: Set<AnyCancellable> = []
private var searchCancellable: AnyCancellable?
init(musicManager: MusicMangerProtocol = MusicManger()) {
self.musicManager = musicManager
}
func searchMusic(searchText: String) {
guard !searchText.isEmpty else {
self.songs = []
return
}
isLoading = true
DispatchQueue.global(qos: .userInitiated).async {
self.musicManager.fetchMusic(with: searchText)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
print(error.localizedDescription)
}
},
receiveValue: { [weak self] songs in
self?.songs = songs
}
)
.store(in: &self.cancellables)
}
}
// 미리듣기 재생 및 일시정지
func pauseMusic(url: URL) {
// 현재 재생 중인 곡이 바뀌면, 기존의 Time Observer를 제거
if let timeObserverToken = timeObserverToken {
player?.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
// 현재 다른 곡이 재생중인지 확인하고, 그렇다면 정지
if let currentlyPlayingURL = currentlyPlayingURL, currentlyPlayingURL != url {
player?.pause()
self.player?.seek(to: .zero)
self.currentlyPlayingURL = nil
}
// 이전과 동일한 곡을 다시 재생하려는 경우
if currentlyPlayingURL == url, player?.timeControlStatus == .playing {
player?.pause()
currentlyPlayingURL = url
isPlaying = false // 재생 중이 아님
} else if currentlyPlayingURL == url, player?.timeControlStatus != .playing {
player?.play()
isPlaying = true // 재생 중
} else {
// 새로운 곡 재생하기
player = AVPlayer(url: url)
player?.play()
currentlyPlayingURL = url
isPlaying = true // 재생 중
setupEndPlaybackObserver()
}
// 새로운 곡을 재생할 때, Time Observer를 추가
if player?.currentItem != nil {
let interval = CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
let mainQueue = DispatchQueue.main
timeObserverToken = player?.addPeriodicTimeObserver(
forInterval: interval,
queue: mainQueue
) { _ in
guard let currentItem = self.player?.currentItem else { return }
let duration = currentItem.duration.seconds
let currentTime = currentItem.currentTime().seconds
let progress = currentTime / duration
self.playbackProgress = Float(progress)
}
}
}
// 미리듣기 끝나는 시점 감지하기
func setupEndPlaybackObserver() {
NotificationCenter.default.removeObserver(
self,
name: .AVPlayerItemDidPlayToEndTime,
object: player?.currentItem
)
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player?.currentItem,
queue: .main
) { _ in
self.player?.seek(to: .zero) // 재생 위치 시작으로 이동
self.currentlyPlayingURL = nil // 현재 재생중인 URL 초기화하기
self.isPlaying = false // 재생 중이 아님
}
}
}
4. 자 이제 화면에 보여지기 위한 속 구조들을 다 설계했으니 View를 만들어보자!
import SwiftUI
#if canImport(UIKit) // 빈 화면 클릭하면 키보드 접기
extension View {
func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
#endif
struct AddMusicToHear: View {
@StateObject private var musicViewModel = MusicViewModel()
@State private var videoURL: URL?
@State private var searchText = ""
@State private var selectedSong: MusicModel?
var body: some View {
NavigationView { // NavigationView 시작
VStack {
HStack {
TextField(text: $searchText) {
Text(
"음악을 검색해주세요."
).foregroundStyle(.gray)
}
.padding(20)
.padding(.leading, 20)
.background(Color("HHTertiary"))
.cornerRadius(10)
.padding(.horizontal) // 화면 좌우 패딩
.foregroundColor(.accent)
.overlay(
HStack {
Image(systemName: "magnifyingglass")
.font(.title3)
.foregroundColor(Color("HHGray"))
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 25)
Spacer()
if !searchText.isEmpty {
Button(action: {
self.searchText = ""
}) {
Image(systemName: "multiply.circle")
.font(.title3)
.foregroundColor(Color("HHGray"))
.padding(.trailing, 25)
}
}
}
)
.onChange(of: searchText) { newValue in
musicViewModel.searchText = newValue
musicViewModel.searchMusic(searchText: searchText)
}
} // HStack TextField요소
.padding(.bottom, 20)
Spacer().frame(maxWidth: .infinity).overlay {
if musicViewModel.isLoading {
// ProgressView()
} else {
musicList
}
}
}
.navigationTitle("우선, 다른 사람들과 공유할 음악을 찾아 볼까요?")
.fullScreenCover(item: $selectedSong) { _ in
CameraHomeView(selectedSong: $selectedSong)
}
.onTapGesture {
self.hideKeyboard()
}
} // NavigationView 종료
} // body 종료
@ViewBuilder
private var musicList: some View {
List(musicViewModel.songs) { song in
musicRow(for: song)
}
.listRowBackground(Color.white)
.background(Color.white)
}
@ViewBuilder
private func musicRow(for song: MusicModel) -> some View {
Button(action: {
// 노래 선택시 CameraHomeView로 넘어가게 하기
self.selectedSong = song
}) {
VStack {
HStack { // HStack 시작
AsyncImage(url: song.artwork) { phase in // AsyncImage 시작
switch phase {
case .empty:
ProgressView()
.frame(width: 70, height: 70)
case .success(let image):
image.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 70, height: 70)
.clipShape(RoundedRectangle(cornerRadius: 10))
case .failure:
Image(systemName: "photo")
.frame(width: 70, height: 70)
@unknown default:
EmptyView()
} // switch 종료
} // AsyncImage 종료
VStack(alignment: .leading) { // VStack 시작
Text(song.title)
.lineLimit(1)
.font(.title3)
Text(song.artist)
.font(.footnote)
.foregroundColor(.gray)
} // VStack 종료
Spacer()
if let previewUrl = song.previewURL {
Button(action: {
musicViewModel.pauseMusic(url: previewUrl)
}) {
Image(systemName: musicViewModel.currentlyPlayingURL == previewUrl && musicViewModel.isPlaying ? "pause.circle" : "play.circle")
.resizable()
.frame(width: 25, height: 25)
}
.buttonStyle(BorderlessButtonStyle())
} // if let 종료
} // HStack 종료
if let previewUrl = song.previewURL, musicViewModel.currentlyPlayingURL == song.previewURL {
ProgressView(value: min(max(musicViewModel.playbackProgress, 0), 1))
.progressViewStyle(LinearProgressViewStyle(tint: Color("HHAccent2")))
}
}
.animation(.easeInOut(duration: 0.5), value: musicViewModel.isPlaying)
}
} // AddMusicToHear View 종료
}
일반적인 searchable을 사용하지 않고 커스텀 텍스트필드창으로 검색기능을 가능하게 만들었다. 그래서 화면의 빈곳을 클릭하면 키보드가 내려가게도 했다.
- 마지막 정리
MusicKit을 사용하면서 문제가 첫번째 음악 데이터를 검색해서 불러올때 authorization 하느라 시간이 걸리고 살짝 버벅거려서
앱을 시작하면서 사용자 모르게 비동기 처리로 "Apple"이라는 의미없는 데이터를 검색하게 하여 첫 검증을 끝내게 되었다.
더 좋은 방법을 찾아봐야겠다...
'iOS > swiftUI' 카테고리의 다른 글
[iOS/SwiftUI] V,HStack 과 LazyVStack 차이 (0) | 2023.03.24 |
---|---|
[SwiftUI] 키보드 focus, 클릭시 사라지기, View가림현상 간단 해결방법 (0) | 2023.03.24 |
[SwiftUI] 이미지캐싱 Kingfisher SDWebImage 차이 & 사용법 (0) | 2023.03.10 |
[SwiftUI] UserDefaults & @AppStorage를 활용한 예제 (0) | 2023.02.12 |
[SwiftUI] Property Wrapper(@ObservedObject, @StateObject, @Environment) 프로퍼티 래퍼 (0) | 2023.01.15 |