HW#32_Apple Music(Music player)

Hello everyone,

Today, I’ll talk about how to create a music player using Swift’s UIKit. In this guide, we will cover:

  1. Implementing the songInfo model from a Swift file, which includes properties like songName, artist, coverName, and backGroundColor.
  2. Implementing play and pause functionality.
  3. Adjusting volume by using UISlider.
  4. Playing the previous or next track.
  5. Changing the gradient background color based on the song.
  6. Displaying the song’s duration.
  7. Displaying the “moreButton” with a UIMenu that contains a few additional functions.

8. Showcasing different devices available through AirPlay.

9. Displaying the album cover with cornerRadius and shadow.

10. Showcasing how to play music when the app is in background mode.

Summary:

I found this practice intriguing as it was my first time making music. There are a few points I want to improve:

  1. Understand and implement the new features of the latest iOS version in Xcode.
  2. Implement Auto Layout to ensure elements fit appropriately on different devices.
  3. Introduce more features for music playback in Demo mode.

1.Implementing the songInfo model from a Swift file.

  • When importing a large amount of data into a Swift application, it’s essential to create a structured data model, like SongInfo, to efficiently organize and manage that data. In the SongInfo model below, there are properties such as songName, artist, coverName, and backGroundColor.
import Foundation
import AVFoundation
import UIKit

struct SongInfo {
var songName : String
var artist : String
var coverName : String
var backGroundColor : [CGColor]
}
  • Create a songList array using the SongInfo structure.
let songList = [

//index == 0
SongInfo(
songName: "rocking chair",
artist: "wavcrush",
coverName: "wavcrushRockingChair",
backGroundColor: [
UIColor(red: 71/255, green: 85/255, blue: 118/255, alpha: 1.0).cgColor,
UIColor(red: 70/255, green: 85/255, blue: 111/255, alpha: 1.0).cgColor,
UIColor(red: 43/255, green: 52/255, blue: 69/255, alpha: 1.0).cgColor ]),

//index == 1
SongInfo(
songName: "0755",
artist: "Lil Jet",
coverName: "6JET",
backGroundColor: [
UIColor(red: 59/255, green: 51/255, blue: 54/255, alpha: 1.0).cgColor,
UIColor(red: 78/255, green: 62/255, blue: 78/255, alpha: 1.0).cgColor,
UIColor(red: 46/255, green: 38/255, blue: 54/255, alpha: 1.0).cgColor ]),

//index == 2
SongInfo(
songName: "心如止水",
artist: "Ice Paper",
coverName: "成語接龍",
backGroundColor: [
UIColor(red: 43/255, green: 43/255, blue: 43/255, alpha: 1.0).cgColor,
UIColor(red: 54/255, green: 54/255, blue: 54/255, alpha: 1.0).cgColor,
UIColor(red: 29/255, green: 29/255, blue: 29/255, alpha: 1.0).cgColor ]),
//index == 3
SongInfo(
songName: "Heat Waves",
artist: "Glass Animals",
coverName: "Heat Waves",
backGroundColor: [
UIColor(red: 143/255, green: 78/255, blue: 174/255, alpha: 1.0).cgColor,
UIColor(red: 145/255, green: 77/255, blue: 174/255, alpha: 1.0).cgColor,
UIColor(red: 123/255, green: 80/255, blue: 169/255, alpha: 1.0).cgColor,
UIColor(red: 75/255, green: 61/255, blue: 112/255, alpha: 1.0).cgColor ])
]

2. Implementing play and pause functionality.

When the song is playing, the album cover becomes bigger; on the other hand, when the song is paused, the cover becomes smaller.

I want to implement automatic music playback. So, I created this function: when the information is loaded, the music starts playing.

Here is the code:

// Playing music
func playMusicAutomatically () {

// Create index change for songList
index = (index + songList.count) % songList.count

// Build the song's location
let url = Bundle.main.url(forResource: songList[index].songName, withExtension: "mp3")!

// Song location login to AVPlayerItem
playerItem = AVPlayerItem(url: url)

// replace current item with a new item.
player.replaceCurrentItem(with: playerItem)

// play song.
player.play()

// musicCoverImageView's image changed by index.
musicCoverImageView.image = UIImage(named: songList[index].coverName)

// artistsLabel's content changed by index.
artistsLabel.text = songList[index].artist

// songNameLabel's content changed by index.
songNameLabel.text = songList[index].songName

// Same frame as view
gradientLayer.frame = view.bounds

// gradientLayerColor's color changed by index.
gradientLayer.colors = songList[index].backGroundColor

// add the view in the bottom view which's layer no.0
view.layer.insertSublayer(gradientLayer, at: 0)
}
  • Regarding the Play and Pause functionalities, I use an if-else statement to determine whether the player is currently playing or not. I also invoke the .timeControlStatus method to ascertain the current status. Additionally, I observed that when the music is paused, the album cover becomes smaller.
    // Change the mode when Btn is play or pause.
@IBAction func statusBtnTapped(_ sender: UIButton) {
if player.timeControlStatus == .playing {
player.pause()
statusBtn.setupPlayBtn()
subView.viewZoomIOut()
print("now is pause")
} else if player.timeControlStatus == .paused {
player.play()
statusBtn.setPauseBtn()
subView.viewZoomIn()
print("now is play")
}
}
  • When the song is played or paused, the coverImage (a UIView) will zoom in or out using CGAffineTransform.identity.scaledBy.
func viewZoomIOut () {
self.transform = CGAffineTransform.identity.scaledBy(x: 0.72, y: 0.72)
}
func viewZoomIn () {
self.transform = CGAffineTransform.identity.scaledBy(x: 1, y: 1)
}

Reference:

3. Adjusting volume by using UISlider.

Adjust the volume using a UISlider.

For the volumeSlider, I'm using volumeSlider.value to set the player.volume. This allows you to adjust the volume using the slider.

    //volumeSlider coordinate with slider's value.
@IBAction func volumeSliderValueChanged(_ sender: UISlider) {
player.volume = Float(volumeSlider.value)
}
Adjust thumbImage.

For the UI, I adjusted the thumbImage and pointSize to make it look more like Apple Music's UI.

   func setupSongLengthThumbImage () {
let configuration = UIImage.SymbolConfiguration(pointSize: 8)
let image = UIImage(systemName: "circle.fill", withConfiguration: configuration)
songLengthSlider.setThumbImage(image, for: .normal)
}

func setupVolumeSliderThumbImage () {
let configuration = UIImage.SymbolConfiguration(pointSize: 8)
let image = UIImage(systemName: "circle.fill", withConfiguration: configuration)
volumeSlider.setThumbImage(image, for: .normal)
}

Reference:

  • Adjust slider’s thumbImage

4. Playing the previous or next track.

Regrading we want to change play Forward or play backward.

Basically I copied the same function as Apple Music, I use the index’s count to do the song changed also play the music.

Example:
index += 1 or index -= 1
  • Play Forward
    // forwardBtn tapped
@IBAction func forwardBtnTapped(_ sender: UIButton) {
index += 1
print(index)
playMusicAutomatically ()
print("forwardBtnTapped")
}
  • Play Backward

hen I was trying to implement the Play backward function in Xcode, I copied the same function for playing backward.

If backwardBtnTappedCount equals 1, then I use player.seek(to: .zero) to return the song to the beginning.

However, if backwardBtnTappedCount is greater than or equal to 1, the song will revert to the previous one.

    // trigger backwardBtn
@IBAction func backwardBtnTapped(_ sender: UIButton) {
backwardBtnTappedCount += 1
if backwardBtnTappedCount == 1 {
player.seek(to: .zero)
playMusicAutomatically ()
print("backwardBtnTappedCount equal 1")
} else if backwardBtnTappedCount >= 1 {
index -= 1
print(index)
playMusicAutomatically ()
print("backwardBtnTappedCount greater than 1")
}
}

5. Changing the gradient background colour based on the song.

Regrading the gradient background is about the how do we do the color gradient changing in the layer.

Create a struct for SongInfo.

struct SongInfo {
var songName : String
var artist : String
var coverName : String
var backGroundColor : [CGColor]
}

Then I defined few backGroundColor content in songList.

let songList = [

//index == 0
SongInfo(
songName: "rocking chair",
artist: "wavcrush",
coverName: "wavcrushRockingChair",
backGroundColor: [
UIColor(red: 71/255, green: 85/255, blue: 118/255, alpha: 1.0).cgColor,
UIColor(red: 70/255, green: 85/255, blue: 111/255, alpha: 1.0).cgColor,
UIColor(red: 43/255, green: 52/255, blue: 69/255, alpha: 1.0).cgColor ]),

//index == 1
SongInfo(
songName: "0755",
artist: "Lil Jet",
coverName: "6JET",
backGroundColor: [
UIColor(red: 59/255, green: 51/255, blue: 54/255, alpha: 1.0).cgColor,
UIColor(red: 78/255, green: 62/255, blue: 78/255, alpha: 1.0).cgColor,
UIColor(red: 46/255, green: 38/255, blue: 54/255, alpha: 1.0).cgColor ]),

//index == 2
SongInfo(
songName: "心如止水",
artist: "Ice Paper",
coverName: "成語接龍",
backGroundColor: [
UIColor(red: 43/255, green: 43/255, blue: 43/255, alpha: 1.0).cgColor,
UIColor(red: 54/255, green: 54/255, blue: 54/255, alpha: 1.0).cgColor,
UIColor(red: 29/255, green: 29/255, blue: 29/255, alpha: 1.0).cgColor ]),
//index == 3
SongInfo(
songName: "Heat Waves",
artist: "Glass Animals",
coverName: "Heat Waves",
backGroundColor: [
UIColor(red: 143/255, green: 78/255, blue: 174/255, alpha: 1.0).cgColor,
UIColor(red: 145/255, green: 77/255, blue: 174/255, alpha: 1.0).cgColor,
UIColor(red: 123/255, green: 80/255, blue: 169/255, alpha: 1.0).cgColor,
UIColor(red: 75/255, green: 61/255, blue: 112/255, alpha: 1.0).cgColor ])
]
  • We need to set up view.bounds and gradientLayer.colors. Also, insert the sublayer at the bottom using layer.insertSublayer, setting that layer's index to 0.
        // Same frame as view
gradientLayer.frame = view.bounds

// gradientLayerColor's color changed by index.
gradientLayer.colors = songList[index].backGroundColor

// add the view in the bottom view which's layer no.0
view.layer.insertSublayer(gradientLayer, at: 0)

Reference:

6. Displaying the song’s duration.

To show the song duration:

There are a few points to note. Due to the iOS 16.0 upgrade introducing new APIs for AVFoundation, the old method of retrieving the duration using asset.duration has been deprecated. We need to find a new approach.

  • Apple suggest us to use this function to get song duration in New iOS 16.
let duration = try? await asset.load(.duration)
  • Create an updateSongDuration() to get full content in this function and do the type annotation in this function and add async into this code lines.
    func updateSongDuration() async {

/// Get songDuration by new solution.
// 1. Define AVURLAsset first.
let url = Bundle.main.url(forResource: songList[index].songName, withExtension: "mp3")!
// 2. Store asset from AVURLAsset location.
let asset = AVURLAsset(url: url, options: nil)
// 3. Get duration from asset
// Make sure asset.load is exist.
let duration = try? await asset.load(.duration)
// 4. Get total seconds (Float) by type annotation from CMTimeGetSeconds.
let songDurationSec = CMTimeGetSeconds(duration!)

// Print out(total DurationSec when song is playing)
print(songDurationSec)

///Get currentTime of song.
// 1. Get currentSongSecs by using CMTimeGetSeconds
let currentSongInSecs = CMTimeGetSeconds(self.player.currentTime())
// 2. Type annotation from float to Int, Int to String.
let currentSongDurationSec = String(format: "%02d", Int(currentSongInSecs) % 60)
// 3. Get minutes from float to Int, Int to String.
let currentSongDurationMin = String(Int(currentSongInSecs) / 60)
// 4.
self.songDurationStartLabel.text = "\(currentSongDurationMin):\(currentSongDurationSec)"

/// Calculate remainSongSec
let remainSongInSecs = songDurationSec - currentSongInSecs
// 1. Same from top solution to get total second.
let remainSongDurationSec = String(format: "%02d", Int(remainSongInSecs) % 60)
// 2. Same from top solution to get minutes.
let remainSongDurationMin = String(Int(remainSongInSecs) / 60)

// Print out(songDurationSec when song is playing)
self.songDurationEndLabel.text = "-\(remainSongDurationMin):\(remainSongDurationSec)"

/// setup songLengSlider from songDurationSec
songLengthSlider.maximumValue = Float(songDurationSec)
songLengthSlider.value = Float(currentSongInSecs)
}
  • Use the player.addPeriodicTimeObserver to get the time.
///Use the player addPeriodicTimeObserver to observe the TIME and set up second equals 1 and  TimeScale count by per second.
func observeSongTimeUpdates() {
player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { [weak self] _ in
Task {
await self?.updateSongDuration()
}
}
}

After we finish it, when we change the song then song duration will show different length.

7. Displaying the “moreButton” with a UIMenu that contains a few additional functions.

Explain how to present UIMenu.
  • Presenting a UIMenu is relatively straightforward. I followed Peter Pan’s article for guidance.
  • From the top of the image, you can see three sections of code. Each section presents a child view, and they are arranged from bottom to top.

Pretty Simple, right?

 //Add when moreBtn tapped Menu shows up
//moreBtn the proiority is upside down, beware of that!
func moreBtnMenuSetup () {
moreBtn.showsMenuAsPrimaryAction = true
moreBtn.menu = UIMenu(children: [
UIAction(title: "Suggest Less", image: UIImage(systemName: "hand.thumbsdown"), handler: { action in
print("Suggest Less")
}),
UIAction(title: "Love", image: UIImage(systemName: "heart"), handler: { action in
print("Love")
}),
UIMenu(options: .displayInline, children: [
UIAction(title: "Create Station", image: UIImage(systemName: "badge.plus.radiowaves.right"), handler: { action in
print("Create Station")
}),
UIAction(title: "Show Album", image: UIImage(systemName: "play.square"), handler: { action in
print("Show Album")
}),
UIAction(title: "Share Lyrics", image: UIImage(systemName: "quote.bubble") ,handler: { action in
print("Share Lyrics")
}),
UIAction(title: "View Full Lyrics", image: UIImage(systemName: "quote.bubble"), handler: { action in
print("View Full Lyrics")
}),
UIAction(title: "SharePlay", image: UIImage(systemName: "shareplay"), handler: { action in
print("SharePlay")
}),
UIAction(title: "Share Song", image: UIImage(systemName: "square.and.arrow.up"), handler: { action in
print("Share Song")
})
]),
UIMenu(options: .displayInline, children: [
UIAction(title: "Add to Library", image: UIImage(systemName: "plus"), handler: { action in
print("Add to Library")
}),
UIAction(title: "Add to a Playlist", image: UIImage(systemName: "text.badge.plus"), handler: { action in
print("Add to a Playlist")
})
])
])
}

Reference:

https://medium.com/彼得潘的-swift-ios-app-開發問題解答集/ios-的選單-menu-按鈕-pull-down-button-pop-up-button-2ddab2181ee5

8. Showcasing different devices available through AirPlay.

Before we start creating the AirPlay functionality, we need to understand when Apple introduced AirPlay and its intended use.

For more detailed information, you can watch this video from WWDC19.

There are very few examples available for the airPlayBtn, so it took me a couple of days to figure out how to implement this feature in my music player app.

First, we need to import AVKit, MediaPlayer, and AVRouting, and also implement the AVRoutePickerViewDelegate, so that AVRoutePickerView can be used.

  • Create a routePickerView using AVRoutePickerView().
import UIKit
import AVKit
import AVFoundation
import MediaPlayer
import AVRouting

class MusicPlayerViewController: UIViewController, AVAudioPlayerDelegate, AVRoutePickerViewDelegate {

// setup routePickerView
let routePickerView = AVRoutePickerView()
// setup routeDetector
let routeDetector = AVRouteDetector()
  • Set up the AirPlay button and then import the routePickerView.delegate to itself.
  override func viewDidLoad() {
super.viewDidLoad()

updateUI ()
playMusicAutomatically ()

// Setup AirPlayButton
routePickerView.delegate = self
setupAirplayButton()

observeSongTimeUpdates()
artistsLabel.adjustsFontSizeToFitWidth = true
songNameLabel.adjustsFontSizeToFitWidth = true

let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
view.addGestureRecognizer(tapGesture)
}
  • Set up routePickerView by function.
From Apple documentation.
    // Setup Route PickerView
func setupAirplayButton() {
//routePickerView frame size.
routePickerView.frame = CGRect(x: 174, y: 600, width: 80, height: 80)

//Set up UI for routePickerView
routePickerView.activeTintColor = UIColor.systemPink
routePickerView.backgroundColor = UIColor.black
routePickerView.tintColor = UIColor.white

//addSubview for routePickerView
self.view.addSubview(routePickerView)
}
  • Trigger the airPlayerBtn action when airPlayerBtnTapped is called.
    // airPlayer tapped
@IBAction func airPlayerBtnTapped(_ sender: UIButton) {
setupAirplayButton()
print("airPlayerBtnTapped")
}

Reference:

Apple Documentation:

Stackoverflow:

9. Displaying the album cover with cornerRadius and shadow.

  • To display the imageView with cornerRadius and shadow, there are two ways to approach this topic.
  1. Place the imageView inside a UIView. Set the cornerRadius for the imageView and add a shadow to the UIView using the storyboard.

2. Use a programmatic approach to set the cornerRadius for the imageView and add a shadow using CALayer's extension in Swift.

I chose the first option because it was quite easy.

I created a UIView and placed the imageView inside it. I then set the cornerRadius for the imageView and added a shadow to the UIView I created.

      func updateUI () {
musicCoverImageView.layer.cornerRadius = cornerRadius
musicCoverImageView.layer.shadowOffset = CGSize(width: 0, height: 10)
musicCoverImageView.clipsToBounds = true

subView.layer.cornerRadius = cornerRadius
subView.layer.shadowColor = UIColor.black.cgColor
subView.layer.shadowOffset = CGSize(width: 5.0, height: 5.0)
subView.layer.shadowRadius = 25.0
subView.layer.shadowOpacity = 0.5

subView.layer.shadowPath = UIBezierPath(roundedRect: musicCoverImageView.bounds, cornerRadius: cornerRadius).cgPath

// Add height for Slider
songLengthSlider.customSongLengthSlider()
volumeSlider.customVolumeSlider()
setupSongLengthThumbImage ()
setupVolumeSliderThumbImage ()

// Add Alpha for btn
lyrisBtn.addBtnAlpha()
airPlayBtn.addBtnAlpha()
listBtn.addBtnAlpha()

// Add Alpha for label
artistsLabel.addLabelAlphaSec()

// Setup SongDurationStartLabel
songDurationStartLabel.text = "--:--"
songDurationStartLabel.addLabelAlpha()

// Setup SongDurationEndLabel
songDurationEndLabel.text = "--:--"
songDurationEndLabel.addLabelAlpha()

// UI for playBtn
statusBtn.setupPlayBtn()
statusBtn.setPauseBtn()

//LyrisBtn trigger
lyrisBtn.addTarget(self, action: #selector(buttonTouchUp), for: .touchDown)
lyrisBtn.addTarget(self, action: #selector(buttonTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])

//airPlayBtn trigger
airPlayBtn.addTarget(self, action: #selector(buttonTouchUp), for: .touchDown)
airPlayBtn.addTarget(self, action: #selector(buttonTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])

listBtn.addTarget(self, action: #selector(buttonTouchUp), for: .touchDown)
listBtn.addTarget(self, action: #selector(buttonTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])
}

10. Showcasing how to play music when the app is in background mode.

  • Add these lines of code to the App delegate to successfully play music in background mode.:

// Playing music when the app is in the background.
try? AVAudioSession.sharedInstance().setCategory(.playback)

// Playing music on an iPhone while it's on the lock screen.
application.beginReceivingRemoteControlEvents()

return true

HW Reference:

Here’s my GitHub repository link:

--

--