MusicPlayer
MusicKit 프레임워크 개발자 공식 문서 살펴보기
MusicCatalog로 음악 검색 및 특정 음악 아이템 조회하기
앞선 두개의 포스팅에서는 음악을 재생하거나 컨트롤하는 방법이 아닌
MusicKit에 대해 이해하고 음악 데이터를
조회하고 검색하는 방법에 대해 알아봤다.
사실 이 정도로 음악을 검색 및 조회하는 방법은
MusicKit이 아니더라도 다른 서비스로도
구현을 할 수 있다.
(물론 MusicKit만큼 간편하지는 않겠지만)
이제 MusicKit에 있는 MusicPlayer를 통해
검색한 음악을 즉시 재생하는 것 까지
구현해보자.
OpenSource Package
추가적으로! 현재 음악 플레이어를 좀 손쉽게 만들기 위한
패키지를 만들고 있다.
사실은 어느정도 완성된 상태이고
크기도 굉장히 작다.
ReadMe.md를 작성하고 있는데,
도움을 주실 분은 대환영입니다😍
음악 플레이어를 만드는데 어려움을 겪고 있다면
한 번 확인해보고 사용해보는 것도 괜찮을 것 같다 ^_^
https://github.com/nex5turbo/AppleMusicPlayer
MusicViewModel 생성하기
먼저 전역적으로 사용할 수 있는 MusicViewModel을 만들어
EnvironmentObject로 설정하도록 하자.
import SwiftUI
import MusicKit
import AVFoundation
class MusicViewModel: ObservableObject {
@Published var status: MusicAuthorization.Status = .notDetermined
@Published var isSubscribed: Bool = false
@Published var isPlaying: Bool = false
@Published var currentSong: Song? = nil
private var previewPlayer: AVPlayer?
public var currentPlayTime: TimeInterval {
if isSubscribed {
return ApplicationMusicPlayer.shared.playbackTime
} else {
return previewPlayer?.currentTime().seconds ?? 0
}
}
}
플레이어를 만드는데 필요한 상태 변수 및 일반 변수들을
포함한 ViewModel이다.
특이점이 있다면 currentPlayTime에 isSubscribed 상태에 따라
ApplicationMusicPlayer를 사용할지
previewPlayer를 사용할지 나눠놨는데,
이유는.....
애플 뮤직을 구독하지 않은 사람들은
음악을 전체 구간 들을 수 없기 때문이다.
이미 1편에서 서술해두긴 했지만,
구독자들만 노래들을 수 있음..
그래도 비구독자들 역시 30초의 정해진 구간을
감상할 수 있게 해뒀는데, 이 정도면
충분히 합리적이라고 생각한다.
하여튼 우리는 모든 함수에서 이 구독 상태에 따라
분기처리를 해줘야한다는 의미이다.
import SwiftUI
import MusicKit
import AVFoundation
class MusicViewModel: ObservableObject {
@Published var status: MusicAuthorization.Status = .notDetermined
@Published var isSubscribed: Bool = false
@Published var isPlaying: Bool = false
@Published var currentSong: Song? = nil
private var previewPlayer: AVPlayer?
public var currentPlayTime: TimeInterval {
if isSubscribed {
return ApplicationMusicPlayer.shared.playbackTime
} else {
return previewPlayer?.currentTime().seconds ?? 0
}
}
@MainActor
func authorize() async {
let status = await MusicAuthorization.request()
self.status = status
}
@MainActor
func checkSubscriptionStatus() async throws {
let subscription = try await MusicSubscription.current
self.isSubscribed = subscription.canPlayCatalogContent
}
@MainActor
func setup(with song: Song) async throws {
if isSubscribed {
ApplicationMusicPlayer.shared.queue = [song]
try await ApplicationMusicPlayer.shared.prepareToPlay()
} else {
if let previewURL = song.previewAssets?.first?.url {
previewPlayer = AVPlayer(url: previewURL)
} else {
print("⚠️ 프리뷰 URL이 없습니다.")
}
}
self.currentSong = song
self.isPlaying = false
}
@MainActor
func play() async throws {
if isSubscribed {
try await ApplicationMusicPlayer.shared.play()
} else {
previewPlayer?.play()
}
self.isPlaying = true
}
@MainActor
func pause() {
if isSubscribed {
ApplicationMusicPlayer.shared.pause()
} else {
previewPlayer?.pause()
}
self.isPlaying = false
}
@MainActor
func seek(to time: TimeInterval) {
if isSubscribed {
ApplicationMusicPlayer.shared.playbackTime = time
} else {
previewPlayer?.seek(to: CMTime(seconds: time, preferredTimescale: 60))
}
}
@MainActor
func stop() {
if isSubscribed {
ApplicationMusicPlayer.shared.stop()
} else {
self.previewPlayer?.pause()
self.previewPlayer = nil
}
self.isPlaying = false
}
}
이 코드는 현재 프로젝트에서 사용중인 ViewModel의 원문이다.
이 코드가 완벽하지는 않은데,
플레이어를 컨트롤 하는 도중에
구독 상태가 변경되면
아직 확인되지 않은 에러 혹은
의도치 않은 동작이 발생할 확률이 크다.
하지만 아직 프로토타입이기도 하고,
그런 상황이 생길 확률은
극히 낮다고 생각해서
일단 이정도로도 충분해보인다.
View에서의 사용
이제 ViewModel을 만들었으니 실제 View에서는
이 뷰모델을 어떻게 사용하는지 알아보자.
@State private var song: Song? = nil
private var duration: TimeInterval {
musicViewModel.isSubscribed ?
song.duration ?? .zero : 30
}
var body: some View {
HStack(spacing: 20) {
AsyncImage(url: song.artwork?.url(width: 512, height: 512)) { phase in
switch phase {
case .empty:
RoundedRectangle(cornerRadius: 20)
.fill(.themePrimary)
.frame(width: 100, height: 100)
case .success(let image):
image
.resizable()
.scaledToFill()
.frame(width: 100, height: 100)
.contentShape(Rectangle())
.clipped()
.background(.themePrimary)
.cornerRadius(20)
case .failure(_):
RoundedRectangle(cornerRadius: 20)
.fill(.themePrimary)
.frame(width: 100, height: 100)
@unknown default:
RoundedRectangle(cornerRadius: 20)
.fill(.themePrimary)
.frame(width: 100, height: 100)
}
}
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text(song.title)
.font(.headline)
.lineLimit(1)
Text(song.artistName)
.font(.caption)
}
Spacer()
HStack(spacing: 20) {
Button {
if musicViewModel.isPlaying {
self.musicViewModel.pause()
} else {
Task {
try? await self.musicViewModel.play()
}
}
} label: {
if self.musicViewModel.isPlaying {
Image(systemName: "pause")
.foregroundStyle(Color.themePrimary)
} else {
Image(systemName: "play")
.foregroundStyle(Color.themePrimary)
}
}
}
.font(.title3)
}
let sliderHeight: CGFloat = 2
let circleSize: CGFloat = 6
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.gray.opacity(0.3)).frame(height: sliderHeight)
Capsule().fill(Color.themeSecondary).frame(width: geo.size.width * sliderValue(width: geo.size.width), height: sliderHeight)
Circle().fill(.themeSecondary)
.frame(width: circleSize, height: circleSize)
.offset(x: geo.size.width * sliderValue(width: geo.size.width))
}
.gesture(
DragGesture()
.onChanged { value in
if !isDragging {
withAnimation {
self.isDragging = true
}
}
self.dragOffset = value.translation.width
}
.onEnded { value in
withAnimation {
self.musicViewModel.seek(
to: sliderValue(
width: geo.size.width
) * duration)
self.isDragging = false
}
self.dragOffset = .zero
}
)
}
.frame(height: sliderHeight)
.scaleEffect(isDragging ? CGSize(width: 1.05, height: 1.05) : CGSize(width: 1, height: 1))
}
}
}
.padding(.horizontal)
.onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in
if !isDragging {
updateProgress()
}
}
}
private func sliderValue(width: CGFloat) -> CGFloat {
if duration == 0 {
return (self.dragOffset / width)
}
return min(1, max(0, (self.currentTime / duration) + (self.dragOffset / width)))
}
private func updateProgress() {
self.currentTime = musicViewModel.currentPlayTime
}
private func timeString(time: TimeInterval) -> String {
let minute = Int(time) / 60
let seconds = Int(time) % 60
return String(format: "%02d:%02d", minute, seconds)
}
꽤 긴데, 최대한 자세하게 작성해보고 싶었다....
사실 플레이어를 만드는데 있어
가장 까다로운 부분은 현재 음악의 재생 상태 및
음악 길이, 현재 플레이 중인 구간 등을
알아내는 것이다.
1. isPlaying을 확인할 상태변수 부재
2. 현재 playtime을 확인할 상태변수 부재
3. 음악이 끝났을 때의 콜백 혹은 Notification 부재
이러한 이유 때문에 ViewModel에 1,2 를
커버할 수 있는 변수를 추가한 것이다.
하지만 아직도 3번의 문제는 해결하지 못했는데,
현재로서는 다음과 같은 방식으로 해결할 생각이다.

개발자 문서를 보면 다음 경로로 해당 status를 조회할 수 있다.
ApplicationMusicPlayer.shared.state.playbackStatus
그리고 해당 변수에는 다음과 같은 데이터들이
포함되어있다.

View코드를 다시 확인해보면 Timer를 설정하고 onReceive에서
0.1초마다 현재 playbacktime을 설정해주고 있는데,
마찬가지로 playbackStatus도 해당 타이머에
등록하여 0.1초마다 확인하고 값을 변경해주는 방식을
고려하고 있다.
playbackTime의 경우는 이런 polling 방식이
잘못된 방식이 아니라는 확신이 있지만,
Status 관련한 부분에서는 polling 방식이 아니라
NotifyChange를 사용하는 방식이
더 낫다고 생각하고 있다.
하지만 아직 다른 방도를 찾지 못했기에.....
일단 폴링 방식으로 playbackTime과 함께
Status도 같이 관찰하여 변경상태를 저장하는게
나에게는 최선인 것 같다....
'iOS' 카테고리의 다른 글
| [Swift] 반려동물 일기 앱 기획 [데이터 정의] - 2 (2) | 2025.07.10 |
|---|---|
| [Swift] 반려동물 일기 앱 기획 - 1 (1) | 2025.07.06 |
| [Swift] MusicKit으로 음악 검색기능 구현하기 - 2 (0) | 2025.06.29 |
| [Swift] MusicKit 내부 살펴보기 - 1 (1) | 2025.06.27 |
| [Swift] Supabase에서 받아온 created_at이 디코딩 되지 않을 때 (0) | 2024.10.17 |