본문 바로가기
iOS

[Swift] MusicPlayer로 음악 플레이어를 만들어보자 - 3

by 워뇨옹2 2025. 6. 30.
728x90
반응형

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도 같이 관찰하여 변경상태를 저장하는게

나에게는 최선인 것 같다.... 

728x90
반응형