Lounge Music Player

[iOS App Development]模仿 iOS 的 Music App 製作情歌點唱機

這次的作業是音樂播放器,主要會實作的功能項目包括:

  1. 利用AVFoundation操控音樂播放等功能。
  2. 播放、暫停音樂。
  3. 可切換上一首、下一首音樂。
  4. 播放完當前音樂會自動播放下一首(重複模式時會同一首)。
  5. 透過slider和label顯示播放時間及剩餘播放時間。
  6. 拉動slider可以調整播放時間。
  7. 可隨機播放某個音樂。
  8. 重複播放模式。
  9. 可以調整音量大小。
  10. 介面相關的圓角、毛玻璃、陰影處理。

介面規劃參考了其他網站的Designer設計的Music App UI,選了一個自己程度內可以handle的就好。

前置: 全域變常數、IBOutlet、各種函式

全域變常數宣告:

//宣告index來擷取音樂清單內特定位置的item
var index = 0
//建立播放器
let player = AVPlayer()
//之後播放時間slider會更新的播放時間,因為會被不同的action取用所以宣告為全域變數
var updatedTime = CMTime()
//使用字典物件陣列音樂的相關檔案名稱與介面需要顯示的資料,共六首音樂
let musics = [
["fileName": "allthat",
"title": "1.All that",
"composer": "Benjamin Tissot"
],
["fileName": "thelounge",
"title": "2.The Lounge",
"composer": "Benjamin Tissot"
],
["fileName": "hipjazz",
"title": "3.Hip Jazz",
"composer": "Benjamin Tissot"
],
["fileName": "jazzyfrenchy",
"title": "4.Jazzy Frenchy",
"composer": "Benjamin Tissot"
],
["fileName": "sexy",
"title": "5.Sexy",
"composer": "Benjamin Tissot"
],
["fileName": "funkysuspense",
"title": "6.Funky Suspense",
"composer": "Benjamin Tissot"
],
]

//建立一個陣列來放要實際用來播放的音樂檔名
//之後要用檔名來當成參數放到生成playItem和介面顯示的函式
var musicsToPlay : [String] = []

IBOutlet:

    //操作面板View,因為會調左上角和右上角為圓角,所以要拉出來
@IBOutlet weak var playerPanelView: UIView!

//目前播放音樂的時間slider
@IBOutlet weak var musicProgressBarSlider: UISlider!

//重複播放button
@IBOutlet weak var loopButton: UIButton!
//隨機播放button
@IBOutlet weak var shuffleButton: UIButton!

//播放or暫停button
@IBOutlet weak var playButton: UIButton!

//音樂圖片imageView
@IBOutlet weak var musicPicImageView: UIImageView!

//音樂名稱label
@IBOutlet weak var musicTitleLabel: UILabel!

//音樂作者label
@IBOutlet weak var composerLabel: UILabel!

//音樂剩餘時間長度label
@IBOutlet weak var durationLabel: UILabel!

//音樂目前播放時間label
@IBOutlet weak var currentTimeLabel: UILabel!

以下只整理出與播放功能與介面相關的函式:

・將播放時間的slider按鈕變小

func setProgressBarThumb() {
//設定要套用的尺寸
let smallConfig = UIImage.SymbolConfiguration(pointSize: 10, weight: .medium, scale: .small)
let thumb = UIImage(systemName: "circle.fill", withConfiguration: smallConfig)
musicProgressBarSlider.setThumbImage(thumb, for: .normal)
}

・設定播放和暫停狀態按鈕

func setPlayButtonImage() {
//設定要套用的尺寸
let largeConfig = UIImage.SymbolConfiguration(pointSize: 65, weight: .light, scale: .large)
let playIcon = UIImage(systemName: "play.circle.fill", withConfiguration: largeConfig)
let pauseIcon = UIImage(systemName: "pause.circle", withConfiguration: largeConfig)

//.normal為暫停模式,所以顯示play圖片
playButton.setImage(playIcon, for: .normal)
//.selected為播放模式,所以顯示pause圖片
playButton.setImage(pauseIcon, for: .selected)
}

・設定重複播放模式與不重複播放模式按鈕

func switchLoopMode() {
//設定要套用的尺寸
let config = UIImage.SymbolConfiguration(pointSize: 32, weight: .regular, scale: .medium)
let loopModeOffIcon = UIImage(systemName: "repeat.circle", withConfiguration: config)
let loopModeOnIcon = UIImage(systemName: "repeat.circle.fill", withConfiguration: config)

//.normal為不重複模式
loopButton.setImage(loopModeOffIcon, for: .normal)
//.selected為重複模式
loopButton.setImage(loopModeOnIcon, for: .selected)
}

・放大隨機播放按鈕圖片

func setShuffleButtonImage() {
let config = UIImage.SymbolConfiguration(pointSize: 32, weight: .regular, scale: .medium)
let shuffleIcon = UIImage(systemName: "shuffle.circle", withConfiguration: config)
shuffleButton.setImage(shuffleIcon, for: .normal)
}

・格式化播放時間與剩餘時間的函式,參數是Double型態的秒數,CMTime可以透過.seconds取得。

func formatedTime(_ secs: Double) -> String {
var timeString = ""
let formatter = DateComponentsFormatter()
//.positional樣式為將時間不同單位以冒號區隔
formatter.unitsStyle = .positional

//只需要使用分跟秒就好
formatter.allowedUnits = [.minute, .second]

//不同秒數下有些需要補0所以有不同的格式
if secs < 10 && secs >= 0 {
timeString = "0:0\(formatter.string(from: secs)!)"
} else if secs < 60 && secs >= 10 {
timeString = "0:\(formatter.string(from: secs)!)"
} else {
timeString = formatter.string(from: secs)!
}
//回傳格式化時間字串
return timeString
}

・設定要播放的音樂資訊,觸發時機為任何切換音樂的事件發生。包括生成playItem、顯示圖片、音樂名稱、作曲者等。參數需要設定檔案名稱,和代表要播放的是第幾首音樂(index)。

func setMusicToPlay(fileName: String, index: Int) {
//生成playItem,並取代成為player的currentItem
let filePath = Bundle.main.url(forResource: fileName, withExtension: ".mp3")!
let playItem = AVPlayerItem(url: filePath)
player.replaceCurrentItem(with: playItem)

//設定音樂名稱、作曲者、音樂圖片
musicTitleLabel.text = musics[index]["title"]!
composerLabel.text = musics[index]["composer"]!
let musicPicture = UIImage(named: "\(musics[index]["fileName"]!).jpeg")
musicPicImageView.image = musicPicture

//抓取playItem的時間長度並轉化為負數的剩餘時間,並調整播放時間的slider最大值
let playItemDuration = playItem.asset.duration.seconds
durationLabel.text = formatedTime(0 - playItemDuration)
//因為seconds是Doublen,所以要轉換為slider value的Float型態
musicProgressBarSlider.maximumValue = Float(playItemDuration)

//重置目前播放時間為00:00
currentTimeLabel.text = "00:00"
}

・觀察目前播放時間,觸發時間為每秒,若播放時間改變就會更新介面。

func musicCurrentTime() {
//timeScale和time照抄官方文件範例,微調為每1秒發動一次
let timeScale = CMTimeScale(NSEC_PER_SEC)
let time = CMTime(seconds: 1, preferredTimescale: timeScale)

//將player掛上週期性時間觀察器,除了最後更新介面的閉包外,其餘參數皆是照抄官方文件和學長姐的作業
player.addPeriodicTimeObserver(forInterval: time, queue: .main, using: { (time) in
//如果「目前播放的item狀態是可以正常播放的」以及「沒有在拖曳播放時間slider時」才執行裡面介面更新程式
if (self.player.currentItem?.status == .readyToPlay) && !self.isDragSlider {
//抓取目前播放的音樂時間
let currentTime = self.player.currentTime().seconds
//宣告剩下的時間
var leftTime = (self.player.currentItem?.duration.seconds)!
//使用手機模擬時會有負數秒數的問題,所以使用if-else處理掉
if currentTime > 0 {
//更新剩下時間
leftTime = (self.player.currentItem?.duration.seconds)! - currentTime
//設定目前時間label的text
self.currentTimeLabel.text = self.formatedTime(currentTime)
//設定音樂播放時間slider的value
self.musicProgressBarSlider.value = Float(currentTime)
} else {
leftTime = (self.player.currentItem?.duration.seconds)!
self.currentTimeLabel.text = self.formatedTime(0)
self.musicProgressBarSlider.value = Float(0)
}
//設定剩餘時間label的text,加上負號
self.durationLabel.text = "-\(self.formatedTime(leftTime))"
}
})
}

ViewDidLoad()初始化

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.

//呼叫播放操作面板加入圓角函式
addRoundCorners(cornerRadius: 60)

//設定播放按鈕圖片
setPlayButtonImage()

//設定播放進度slider按鈕
setProgressBarThumb()

//設定重複模式按鈕圖片
switchLoopMode()
//設定隨機播放圖片
setShuffleButtonImage()

//將要播放的音樂檔名擷取出來放到musicsToPlay陣列
for music in musics {
musicsToPlay.append(music["fileName"]!)
}

//初始化player要播放的音樂和介面
setMusicToPlay(fileName: musicsToPlay[0], index: 0)

//呼叫加入播放時間觀察器函式
musicCurrentTime()

//加上一個觀察器,播放完時自動播放下一個item,依據是否重複播放index會自動改變或者不改變播放同一首音樂
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil, queue: .main) { (_) in
if !self.loopButton.isSelected {
self.index = (self.index + 1) % self.musicsToPlay.count
self.setMusicToPlay(fileName: self.musicsToPlay[self.index], index: self.index)
} else {
self.setMusicToPlay(fileName: self.musicsToPlay[self.index], index: self.index)
}
self.player.play()
}
}

IBAction設置

・播放或暫停button

//播放或暫停按鈕,按一下會切換按鈕的.isSelected,若為true代表播放
//預設進入App時為暫停模式
@IBAction func playOrPauseButton(_ sender: UIButton) {
if !sender.isSelected {
player.play()
sender.isSelected = true
} else {
player.pause()
sender.isSelected = false
}
}

・拉動播放時間slider會有三個action,分別是touch down、touch up inside和value changed。要注意slider為了做到拖拉時目前時間label會持續變動所以在inspector中仍會勾選continuous updates。

touch down event:指的是當開始拖曳slider上的thumb時就會觸發。

touch up inside:當放開thumb時會觸發。

value changed:預設的事件,但實測發現和touch up inside並存時,value changed會較早觸發執行。

@IBAction func progressThumbPressed(_ sender: Any) {
//開始按住thumb時改變isDragSlider狀態,true時時間觀察器不運作
isDragSlider = true
}

@IBAction func changeProgressSlider(_ sender: UISlider) {
//value changed每次觸發都會更新updatedTime,並且更新currentTimeLabel,這個event只會更新要播放的時間點
updatedTime = CMTime(value: Int64(sender.value), timescale: 1)
currentTimeLabel.text = formatedTime(updatedTime.seconds)
}
@IBAction func progreeThumbNotPressed(_ sender: Any) {
//到手指放開後才會真正讓播放器時間移到updatedTime
player.seek(to: updatedTime)
//實測手機模擬手指放開繼續播放時thumb會有閃現回原本播放時間點的問題,故此段程式碼在於解決閃現的問題,應該是時間觀察器太快啟動運作,故延遲執行isDragSlider狀態改變0.5秒就不會再閃現
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Change `2.0` to the desired number of seconds.
// Code you want to be delayed
self.isDragSlider = false
}
}

・點按▶︎▶︎和◀︎◀︎可以播放上一首和下一首音樂。

//點按next按鈕會播放下一個item,若為暫停模式也會切換成播放模式
@IBAction func nextButton(_ sender: Any) {
index = (index + 1) % musicsToPlay.count
setMusicToPlay(fileName: musicsToPlay[index], index: index)
playButton.isSelected = true
player.play()
}

//點按previous按鈕會播放前一個item,若為暫停模式會切換成播放模式
@IBAction func previousButton(_ sender: Any) {
index = (index + musicsToPlay.count - 1) % musicsToPlay.count
setMusicToPlay(fileName: musicsToPlay[index], index: index)
playButton.isSelected = true
player.play()
}

・點按隨機按鈕會隨機播放某首音樂。

//點按隨機播放按鈕會從musicsToPlay中隨機挑選一首音樂播放
@IBAction func randomMusicButton(_ sender: Any) {
index = Int.random(in: 0...musicsToPlay.count - 1)
setMusicToPlay(fileName: musicsToPlay[index], index: index)
}

・點按重複模式可以切換為重複或者不重複模式

//點按重複播放模式按鈕會切換模式,預設進入App為不重複模式
@IBAction func loopMusicButton(_ sender: UIButton) {
if !sender.isSelected {
sender.isSelected = true
} else {
sender.isSelected = false
}
}

・調整音量

@IBAction func volumnChangeSlider(_ sender: UISlider) {
player.volume = sender.value
}

完成品Demo影片:

Github:

音樂來源:

--

--