來寫個簡單的音樂播放器(下)

Una
彼得潘的 Swift iOS / Flutter App 開發教室
18 min readJul 27, 2023

結果還是做了下集,而且事實上一點都不簡單..好多語法都不是很熟悉,花好多時間查及看文章,歌詞依舊沒做出來等到有做出來再來更新~

上集在這裡

最終成果

1/延續上集UI介面稍微調整了一下,包含背景改成漸層,播放按鍵原本用image設定,但需要寫程式改尺寸稍嫌麻煩改為Background的方式..等

  • 背景改為漸層
func setupGradientBackground() {
let gradientLayer = CAGradientLayer()
gradientLayer.frame = view.bounds
gradientLayer.colors = [
CGColor(srgbRed: 53/255, green: 91/255, blue: 98/255, alpha: 1),
CGColor(srgbRed: 31/255, green: 49/255, blue: 57/255, alpha: 1)
]
view.layer.insertSublayer(gradientLayer, at: 0)
}

參考資料:

  • 播放的UIButton改為Background顯示,依照下圖修改button就可以讓圖示跟按鈕外框的大小一致。
  • slider thumb改大小
func setupSlider() {
let thumbImage = UIImage(systemName: "circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15))
timeSlider.setThumbImage(thumbImage, for: .normal)
timeSlider.tintColor = .white
}

以為這樣改完就好了結果再滑動的時候發生離奇的變大事件

拉動時就自己變大

才發現原來是在storyboard的時候應該要Thumb Tint改為default

2/slider音樂時間軸,需要取得音樂總時長,已經經過的時間,所以會需要用到CMTime,player.addPeriodicTimeObserve

  • CMTime說明
// CMTime 的定義
struct CMTime {

// 表示的時間值。它表示時間的數量,例如影片的第幾幀或音訊樣本的數量。
var value: CMTimeValue

// 表示的時間刻度。它表示每秒的時間刻度數量,用於計算時間的單位,例如 30 表示每秒 30 幀。
var timescale: CMTimeScale

// 表示的標誌。這是一些標誌位,用於指示時間的一些特殊屬性,例如是否是無限時間、是否溢出等。
var flags: CMTimeFlags

// 表示的時間的開始點。它通常是 0,但在某些情況下,可以用來表示不同的時間起點。
var epoch: CMTimeEpoch
}

建立 CMTime 物件:

let timeValue = CMTimeValue(60) // 60 個時間單位
let timeScale = CMTimeScale(1) // 1 秒內的時間刻度
let time = CMTime(value: timeValue, timescale: timeScale)

CMTime 轉換為秒數:

let seconds = CMTimeGetSeconds(time)

進行時間的比較:

let time1 = CMTime(value: 60, timescale: 1) // 60 秒
let time2 = CMTime(value: 30, timescale: 1) // 30 秒

if CMTimeCompare(time1, time2) == 1 {
print("time1 is greater than time2")
} else if CMTimeCompare(time1, time2) == -1 {
print("time1 is less than time2")
} else {
print("time1 and time2 are equal")
}

進行時間的加減:

let time1 = CMTime(value: 60, timescale: 1) // 60 秒
let time2 = CMTime(value: 30, timescale: 1) // 30 秒

let sumTime = CMTimeAdd(time1, time2) // 90 秒
let subtractTime = CMTimeSubtract(time1, time2) // 30 秒

進行時間的乘法和除法

let time1 = CMTime(value: 60, timescale: 1) // 60 秒

let multipliedTime = CMTimeMultiplyByFloat64(time1, multiplier: 2.5) // 150 秒
let dividedTime = CMTimeMultiplyByFloat64(time1, multiplier: 0.5) // 30 秒
  • addPeriodicTimeObserver說明

AVPlayer 的一個方法,用於註冊一個定期調用的觀察者,以便在播放進度改變時接收通知。

func addSongDuration() {
// timeScale 是 CMTimeScale 類型的變數,它用於表示時間刻度的數量。在這裡,我們使用 NSEC_PER_SEC,它是一個宏,表示每秒的納秒數。將這個時間刻度作為 addPeriodicTimeObserver 的間隔,意味著觸發頻率將是每秒一次。
let timeScale = CMTimeScale(NSEC_PER_SEC)
// interval 是 CMTime 類型的變數,它用於表示時間間隔。這裡我們使用 CMTime 的便利初始化方法 seconds:preferredTimescale:,將 seconds 參數設置為 1 秒,並將 preferredTimescale 參數設置為前面定義的 timeScale 變數,這樣我們就得到了每秒一次的時間間隔。
let interval = CMTime(seconds: 1, preferredTimescale: timeScale)
// mainQueue 是一個 DispatchQueue,它用於在主線程上運行觸發的 closure。由於在觸發進度觀察器的 closure 時,通常會更新 UI 或其他與主線程相關的操作,因此我們將觸發的 closure 放在主線程上執行,以避免在非主線程上進行 UI 更新而導致的問題。
let mainQueue = DispatchQueue.main

timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: mainQueue) { [weak self] time in
// ... 這裡是觀察到的時間變化的處理程式碼
}
}
  • 哪裡加監控

因為切換每首歌的時候就需要相關的資料內容,於是我加在changeSong的function中

// 添加歌曲播放時間觀察器
func addSongDuration() {
let timeScale = CMTimeScale(NSEC_PER_SEC)
let interval = CMTime(seconds: 1, preferredTimescale: timeScale)
let mainQueue = DispatchQueue.main

timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: mainQueue) { [weak self] time in
guard let self = self else { return } // 使用weak self避免retain cycle

if self.player.timeControlStatus == .playing {
// 取得歌曲總長度
let songDuration = self.player.currentItem?.duration
let formattedDuration = self.formatTimeDuration(songDuration)

// 取得當前播放時間
let currentTime = CMTimeGetSeconds(time)
let formattedCurrentTime = self.formatTimeDuration(CMTime(seconds: currentTime, preferredTimescale: timeScale))

// 更新播放時間的 UI 顯示
self.setTimeUI(passTime: formattedCurrentTime, durationTime: formattedDuration)
}
}

// 更新歌曲資訊及視圖顯示
func changeSong() {
// ...其他程式碼先忽略

// 重新加入時間觀察器
addSongDuration()
}

後來加完監控後,切換下一首歌時出現Thread 1: Fatal error: Double value cannot be converted to Int because it is either infinite or NaN的錯誤

其原因因為在播放下一首歌曲時,監控正在觀察上一首歌曲,但當音樂換曲時未有效更新,導致出現錯誤。解決這個問題的方法是在切換歌曲前,先移除之前的時間觀察器,避免不必要的回調,然後在切換完歌曲後再重新加入時間觀察器。

於是在切換歌曲時都需要先移除監控:

// 移除時間觀察器的方法
func removeTimeObserver() {
if let token = timeObserverToken {
player.removeTimeObserver(token)
timeObserverToken = nil
}
}

func preSong() {
// ...省略其他程式碼
removeTimeObserver() // 移除時間觀察器
changeSong()
}

func nextSong() {
// ...省略其他程式碼
removeTimeObserver() // 移除時間觀察器
changeSong()
}

func changeSong() {
// ... 其他程式碼 ...

// 移除前一首歌曲的時間觀察器
removeTimeObserver()

// 切換歌曲
replaceSong()

// 重新加入時間觀察器
addSongDuration()
}
  • 實現拉動改變歌曲播放時間

我們必須要知道使用者放開的時候時間軸到哪裡了,所以UISlider有一個屬性為isTracking用來監控是不是放開了

// 修改歌曲播放進度的IBAction
@IBAction func changeTime(_ sender: UISlider) {
if sender.isTracking {
let time = CMTime(value: CMTimeValue(Int(sender.value)), timescale: 1)
player.seek(to: time)
player.play()
}
}

這樣寫完之後發現thumb在播放中拉一次就會抖動,原因是因為在 addSongDuration() 方法中,每秒鐘都會更新一次 UISlidervalue 屬性,以顯示目前播放的進度。而在這個更新的過程中,UISlidervalue 屬性會被改變,這導致了抖動的現象。

於是在每次放開後我會判斷是否為播放中的音樂,然後先暫停音樂,等調整好播放區間才開始播音樂

@IBAction func changeTime(_ sender: UISlider) {

if sender.isTracking {
// 判斷是否為播放中的音樂
let isCurrentPlaying = player.timeControlStatus == .playing ? true : false
// 是播放中的音樂就先暫停
if isCurrentPlaying {
player.pause()
}
let time = CMTime(value: CMTimeValue(Int(sender.value)), timescale: 1)
player.seek(to: time)
// 是播放中的音樂再繼續播放
if isCurrentPlaying {
player.play()
}
}
}

3/播放完一首後繼續播放,需要用到NotificationCenter.default.addObserver的方法

  • NotificationCenter.default.addObserver說明
// observer: 要註冊的觀察者,通常是一個物件,該物件需要能夠接收並處理特定通知。
// selector: 接收通知時要呼叫的方法,這個方法需要符合Selector型別,通常會使用#selector語法來指定對應的方法。
// name: 要監聽的通知名稱,通知的名稱通常是一個唯一的字串,用來標識不同的通知。如果想要接收所有通知,可以設定為nil。
// object: 通知的發送者,可以設定為特定物件來過濾只接收該物件發送的通知,或者設定為nil接收所有來自任意物件的通知。
class MyViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 註冊觀察者來接收名稱為"myNotification"的通知
NotificationCenter.default.addObserver(self, selector: #selector(handleNotification(_:)), name: Notification.Name("myNotification"), object: nil)
}

@objc func handleNotification(_ notification: Notification) {
// 在這裡處理收到的通知
// 你可以透過`notification.userInfo`取得通知中附帶的資訊
if let userInfo = notification.userInfo {
// 根據userInfo中的資訊進行處理
}
}
}

這裡我們需要在一開始就掛載這個方法(viewDidLoad中),播完歌後就會自動調用他

override func viewDidLoad() {
// 省略其他程式碼...
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil, queue: .main) { _ in
if self.isRepeat {
self.repeatSong()
} else if !self.isRandom {
self.nextSong()
} else {
self.randomSong()
}
self.playSong()
}
}

4/隨機播放音樂

  • 在全局設定一個變數儲存是不是隨機模式變數
var isRandom = false
  • 在隨機的按鈕上綁定Action事件
// 隨機播放按鈕的IBAction
@IBAction func onRandom(_ sender: UIButton) {
isRandom = !isRandom
}
  • 建立隨機播放的事件,為了避免隨機到現在正在播的歌曲於是需要使用repeat…while來做判斷
// 隨機播放下一首歌曲
func randomSong() {
var randomIndex: Int = currentIndex // 將 randomIndex 初始化為 currentIndex 的初始值
repeat {
randomIndex = Int.random(in: 0...2)
} while randomIndex == currentIndex
logger.log("進入隨機 \(randomIndex)")
removeTimeObserver()
currentIndex = randomIndex
changeSong()
}
  • 放到切換歌曲的事件中

override func viewDidLoad() {
// 當一首歌播完時
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil, queue: .main) { _ in
if self.isRepeat {
self.repeatSong()
} else if !self.isRandom {
self.nextSong()
} else {
self.randomSong()
}
self.playSong()
}
}

// 切換到下一首歌曲
func nextSong() {
if !isRandom {
currentIndex = (currentIndex == songs.count - 1) ? 0 : (currentIndex + 1)
removeTimeObserver()
changeSong()
} else {
randomSong()
}
}

5/重複播放

  • 在全局設定一個變數儲存是不是重複播放模式變數
var isRepeat = false
  • 在重複播放按鈕新增Action事件
// 重複播放按鈕的IBAction
@IBAction func onRepeat(_ sender: UIButton) {
isRepeat = !isRepeat
// 根據 isRepeat 的狀態來更改重複播放按鈕的圖示
if isRepeat {
sender.setImage(UIImage(systemName: "repeat.1"), for: .normal)
} else {
sender.setImage(UIImage(systemName: "repeat"), for: .normal)
}
}
  • 建立重複播放事件(不改變現在播放的音樂index)
// 重複播放目前歌曲
func repeatSong () {
changeSong()
}
  • 放到自動下一首歌曲的事件中
override func viewDidLoad() {
// 當一首歌播完時
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil, queue: .main) { _ in
if self.isRepeat {
self.repeatSong()
} else if !self.isRandom {
self.nextSong()
} else {
self.randomSong()
}
self.playSong()
}
}

github(選擇play-music分支)

參考資料

--

--