Lounge Music Player
[iOS App Development]模仿 iOS 的 Music App 製作情歌點唱機
這次的作業是音樂播放器,主要會實作的功能項目包括:
- 利用AVFoundation操控音樂播放等功能。
- 播放、暫停音樂。
- 可切換上一首、下一首音樂。
- 播放完當前音樂會自動播放下一首(重複模式時會同一首)。
- 透過slider和label顯示播放時間及剩餘播放時間。
- 拉動slider可以調整播放時間。
- 可隨機播放某個音樂。
- 重複播放模式。
- 可以調整音量大小。
- 介面相關的圓角、毛玻璃、陰影處理。
介面規劃參考了其他網站的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:
音樂來源: