AVPlayer

SteadySlower·2022년 1월 27일
0

iOS Development

목록 보기
3/38

3번째 주제는 소리 재생에 관한 것입니다. 간단한 음악 플레이어를 만들어 보면서 핵심 개념을 배워보도록 하겠습니다.

핵심 객체 정리

AVPlayerItem

소리 파일 1개를 객체화한 것입니다. asset 혹은 url을 가지고 init할 수 있습니다.

AVPlayer

실제 소리를 재생하는 역할을 하는 객체입니다. 재생, 정지 등의 소리 재생과 관련된 다양한 메소드를 가지고 있습니다.

기초 (재생, 일시정지)

UI 준비

넉넉한 크기의 버튼 재생버튼과 일시정지 버튼만 추가해두었습니다. 각각의 버튼은 재생과 일시정지를 구현할 셀렉터에 연결해두었습니다.

import UIKit

class VC3: UIViewController {
    
    // MARK: Properties

    let player = AVPlayer()
    
    let playButton: UIButton = {
        let button = UIButton()
        button.setImage(UIImage(systemName: "play.fill"), for: .normal)
        button.tintColor = .black
        button.widthAnchor.constraint(equalToConstant: 50).isActive = true
        button.heightAnchor.constraint(equalToConstant: 50).isActive = true
        button.contentVerticalAlignment = .fill
        button.contentHorizontalAlignment = .fill
        button.addTarget(self, action: #selector(play), for: .touchUpInside)
        return button
    }()
    
    let pauseButton: UIButton = {
        let button = UIButton()
        button.setImage(UIImage(systemName: "pause.fill"), for: .normal)
        button.tintColor = .black
        button.widthAnchor.constraint(equalToConstant: 50).isActive = true
        button.heightAnchor.constraint(equalToConstant: 50).isActive = true
        button.contentVerticalAlignment = .fill
        button.contentHorizontalAlignment = .fill
        button.addTarget(self, action: #selector(pause), for: .touchUpInside)
        return button
    }()
    
    // MARK: LifeCycle

    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
    }
    
    // MARK: Selector
    
    @objc func play() {
        
    }
    
    @objc func pause() {
        
    }
    
    // MARK: Helpers
    
    func configureUI() {
        view.backgroundColor = .white
        
        view.addSubview(playButton)
        playButton.translatesAutoresizingMaskIntoConstraints = false
        playButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: -50).isActive = true
        playButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -100).isActive = true
        
        view.addSubview(pauseButton)
        pauseButton.translatesAutoresizingMaskIntoConstraints = false
        pauseButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 50).isActive = true
        pauseButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -100).isActive = true
    }

}

AVPlayer에 AVPlayerItem 넣기

번들에 소리 파일을 넣어둡니다. 위에 설명했듯이 AVPlayerItem은 url 혹은 asset으로 init할 수 있습니다. 여기서는 url로 init했습니다.

해당 AVPlayerItem를 AVPlayer의 replaceCurrentItem 메소드로 현재 아이템으로 지정하면 됩니다.

func configurePlayer() {
    guard let url = Bundle.main.url(forResource: "sound", withExtension: "mp3") else {
        print("Failed to load sound")
        return
    }
    let item = AVPlayerItem(url: url)
    player.replaceCurrentItem(with: item)
}

재생, 일시 정지

재생과 일시정지는 아주 간단합니다. 각각 AVPlayer의 play(), pause() 메소드를 사용하면 됩니다.

@objc func play() {
    player.play()
}

@objc func pause() {
    player.pause()
}

고급 (재생 시간 표시 및 탐색)

UI 준비

위 UI에 탐색을 위한 슬라이더와 현재시간과 전체시간을 표시하기 위한 레이블을 하나씩 추가 합니다.

슬라이더에는 2가지 함수를 연결하는데요. 첫 번째는 탐색을 시작할 때와 끝날 때 각각 탐색 중인지 알려주는 isSeeking 변수를 토글하는 셀렉터이고요. 두 번째는 슬라이더의 값이 바뀌었을 때 실제 탐색을 수행할 셀렉터입니다.

import UIKit
import AVFoundation

let buttonSize: CGFloat = 30

class VC3: UIViewController {
    
    // MARK: Properties
    
    let player = AVPlayer()
    
    let playButton: UIButton = {
        let button = UIButton()
        button.setImage(UIImage(systemName: "play.fill"), for: .normal)
        button.tintColor = .black
        button.widthAnchor.constraint(equalToConstant: buttonSize).isActive = true
        button.heightAnchor.constraint(equalToConstant: buttonSize).isActive = true
        button.contentVerticalAlignment = .fill
        button.contentHorizontalAlignment = .fill
        button.addTarget(self, action: #selector(play), for: .touchUpInside)
        return button
    }()
    
    let pauseButton: UIButton = {
        let button = UIButton()
        button.setImage(UIImage(systemName: "pause.fill"), for: .normal)
        button.tintColor = .black
        button.widthAnchor.constraint(equalToConstant: buttonSize).isActive = true
        button.heightAnchor.constraint(equalToConstant: buttonSize).isActive = true
        button.contentVerticalAlignment = .fill
        button.contentHorizontalAlignment = .fill
        button.addTarget(self, action: #selector(pause), for: .touchUpInside)
        return button
    }()
    
    let timeSlider: UISlider = {
        let slider = UISlider()
        slider.thumbTintColor = .black
        slider.addTarget(self, action: #selector(toggleIsSeeking), for: .editingDidBegin)
        slider.addTarget(self, action: #selector(toggleIsSeeking), for: .editingDidEnd)
        slider.addTarget(self, action: #selector(seek(sender:)), for: .valueChanged)
        return slider
    }()
    
    var isSeeking: Bool = false

    let currentTimeLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 20)
        label.text = "현재 시간"
        return label
    }()
    
    let fullTimeLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 20)
        label.text = "전체 시간"
        return label
    }()
    
    // MARK: LifeCycle

    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
        configurePlayer()
    }
    
    // MARK: Selector
    
    @objc func play() {
        player.play()
    }
    
    @objc func pause() {
        player.pause()
    }
    
    @objc func seek() {
        
    }

    @objc func toggleIsSeeking() {
        isSeeking.toggle()
    }
    
    // MARK: Helpers
    
    func configureUI() {
        view.backgroundColor = .white
        
        view.addSubview(playButton)
        playButton.translatesAutoresizingMaskIntoConstraints = false
        playButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: -30).isActive = true
        playButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -100).isActive = true
        
        view.addSubview(pauseButton)
        pauseButton.translatesAutoresizingMaskIntoConstraints = false
        pauseButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 30).isActive = true
        pauseButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -100).isActive = true
        
        view.addSubview(timeSlider)
        timeSlider.translatesAutoresizingMaskIntoConstraints = false
        timeSlider.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 10).isActive = true
        timeSlider.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -10).isActive = true
        timeSlider.bottomAnchor.constraint(equalTo: playButton.topAnchor, constant: -20).isActive = true
        
        view.addSubview(currentTimeLabel)
        currentTimeLabel.translatesAutoresizingMaskIntoConstraints = false
        currentTimeLabel.leftAnchor.constraint(equalTo: timeSlider.leftAnchor).isActive = true
        currentTimeLabel.bottomAnchor.constraint(equalTo: timeSlider.topAnchor, constant: -10).isActive = true
        
        view.addSubview(fullTimeLabel)
        fullTimeLabel.translatesAutoresizingMaskIntoConstraints = false
        fullTimeLabel.rightAnchor.constraint(equalTo: timeSlider.rightAnchor).isActive = true
        fullTimeLabel.bottomAnchor.constraint(equalTo: timeSlider.topAnchor, constant: -10).isActive = true
    }
    
    func configurePlayer() {
        guard let url = Bundle.main.url(forResource: "sound", withExtension: "mp3") else {
            print("Failed to load sound")
            return
        }
        let item = AVPlayerItem(url: url)
        player.replaceCurrentItem(with: item)
    }
}

탐색 구현하기

AVPlayer는 seek이라는 메소드를 제공합니다. 다만 해당 메소드는 CMTime 객체를 인자로 받습니다.

CMTime 객체에 대해서 간단하게 소개를 해드리면 시간을 분수로 표현한 것이라고 보시면 됩니다. 분자가 클 수록 시간의 양이 많은 것이고 분모가 클 수록 시간의 정확도가 높은 것이라고 보시면 됩니다. (CM의 core media를 의미합니다. AVFoundation의 상위에 있는 미디어 관련 모듈이라고 보시면 됩니다.)

슬라이더의 위치는 기본적으로 0 ~ 1의 숫자로 나타납니다. 슬라이더의 value 값으로 시간대를 계산하면 됩니다. AVPlayerItem 재생 시간의 총 길이는 duration 속성으로 알 수 있습니다. 이는 CMTime으로 표시되는데 이를 seconds라는 속성으로 Double 값으로 바꾸어 계산할 수 있습니다.

마지막으로 계산한 Double값을 CMTime으로 다시 바꾸어 메소드의 인자로 전달하면 됩니다!

@objc func seek(sender: UISlider) {
    // ✅ 현재 아이템을 가져온다 (없으면 함수 종료)
    guard let currentItem = player.currentItem else { return }

    // ✅ 슬라이더의 위치를 가져와서 이동할 시간으로 바꾼다.
    let position = Double(sender.value)
        // 👉 slide의 현재 위치 (0 ~ 1)를 Float -> Double
    let seconds = position * currentItem.duration.seconds
        // 👉 이동할 시간대를 계산
    let time = CMTime(seconds: seconds, preferredTimescale: 100)
        // 👉 CMTime 객체로 변경, 100은 소수점 아래 2째자리 까지만 쓰겠다는 뜻 (분모)

    // ✅ 해당 시간대로 이동
    player.seek(to: time)
}

🚫  CMTime init 관련 주의사항!

CMTime은 여러 개의 initializer가 있습니다. 아래 두 initializer는 그 의미가 다르니 잘 보고 사용해야 합니다.

CMTime(value: 100, timescale: 100)
	//👉 100 / 100 즉 1초를 의미함.

CMTime(seconds: 100, preferredTimescale: 100)
	//👉 100초를 분모 100으로 나타낸다 즉 10000 / 100을 의미함.

재생 시간 실시간 업데이트

옵저버 달기

AVPlayer 객체에는 특정 시간마다 어떤 클로저를 실행하도록 옵저버를 추가할 수 있습니다. 첫 인자로 시간 간격을 받습니다.

다음으로는 어떤 DispatchQueue에서 해당 클로저를 실행할 것인지를 인자로 받습니다. 우리가 할 작업은 UI와 관련된 작업이므로 메인 큐에서 실행합시다.

마지막 인자는 실행할 클로저입니다. 순환참조를 방지하기 위해서 weak self를 사용했습니다.

func configureObserver() {
    let interval = CMTime(seconds: 1, preferredTimescale: 100)
    player.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main) { [weak self] _ in
        self?.updateTime()
    }
}

UI 업데이트 함수

옵저버의 클로저에서 실행할 클로저를 구현합니다. 재생 중인 현재 시간과 AVPlayerItem의 전체 시간을 가져와서 레이블의 text에 각각 할당합니다.

마지막으로 탐색 중이 아닐 때만 슬라이더의 value를 업데이트 하도록 합시다. (탐색 중에 업데이트하게 되면 탐색 중에 슬라이더 값이 바뀌어서 탐색이 안됩니다.)

func updateTime() {
    let currentTime = self.player.currentItem?.currentTime().seconds ?? 0
    let totalTime = self.player.currentItem?.duration.seconds ?? 0
    
    self.currentTimeLabel.text = "\(Int(currentTime))"
    self.fullTimeLabel.text = "\(Int(totalTime))"
    
    if !isSeeking {
        self.timeSlider.value = Float(currentTime / totalTime)
    }
}

결과

1초마다 현재 재생 시간이 업데이트 되고 슬라이더를 통해 탐색을 할 수 있는 것도 볼 수 있습니다.

profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

0개의 댓글