#38 Game introduction with theme song

This app is by far the most complex one I’ve ever done. Of course, I’ll be making things even more complicated in the future. But I think this is the first milestone for me. Again… let’s dive into it.

Let’s delve into the challenges I encountered during this project. I’ll list each one below. To be honest, they were a bit overwhelming for me, but I’m glad to say that I managed to overcome them successfully.

- The techniques that were used in this app

- What is CMTime and where to use it

- What is NaN and infinity

- music slider control

- How to prevent music from being reloaded

- How to prevent array index from out-of-range / ternary operator

- The tricks of view compositing

Technique used

I used the following techniques to complete the project. It looks not much, and even simple for some people. Yet they are pretty important for settling the fundamentals as a rookie engineer.

  • IBOutlet, IBAction
  • Use code to create views and add subviews with code
  • Use the AVFoundation framework
  • Get seconds from the music player
  • Set the desired time for the music player
  • Time observer
  • Create gradient layer

What is CMTime and where to use it

CMTime definition

Now to wrap it up. The CMTime struct is commonly used in media-related tasks, such as handling video and audio timestamps, synchronization, seeking, and frame rates. It provides a powerful and flexible way to represent and manipulate time values in media contexts.

It let us measure time in a very precise way.

I use it in a time observer, in the function addPeriodicTimeObserver requires a time interval. And the interval is a CMTime type.

// This function sets up an observer to call every 0.5 seconds.
func playerObserver() {
var dur: Float = 0.0 // Variable to store the duration of the music in seconds.
var sec: Float = 0.0 // Variable to store the current playback time in seconds.

// Create a time interval of 0.5 seconds using CMTime.
let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))

// Add a periodic time observer to the musicPlayer.
// This block of code will be executed every 0.5 seconds as specified by the interval.
musicPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { CMTime in

// Get the duration of the currently playing item in seconds and store it in 'dur'.
dur = Float(self.musicPlayer.currentItem?.duration.seconds ?? 0)

// Get the current playback time in seconds and store it in 'sec'.
sec = Float(self.musicPlayer.currentTime().seconds)

// Calculate the remaining time in seconds.
var rem = (dur - sec)

// Calculate the current playback time as a fraction of the total duration.
let val = sec / dur

// Check if 'rem' is NaN (Not a Number) or Infinite. If so, set it to 0.
guard !(rem.isNaN || rem.isInfinite) else {
return rem = 0
}

// Update the musicSliderView's value to represent the progress of the playback.
self.musicSliderView.value = val

// Format the current time and remaining time as minutes and seconds with leading zeros.
let currentSecText = String(format: "%.2d", Int(sec) % 60)
let currentMinText = String(Int(sec) / 60)
let remainSecText = String(format: "%.2d", Int(rem) % 60)
let remainMinText = String(Int(rem) / 60)

// Update the labels to display the current and remaining time in the format "mm:ss".
self.currentTime.text = currentMinText + ":" + currentSecText
self.duration.text = remainMinText + ":" + remainSecText
}
}

What is NaN and infinity?

This error is killing me, cause I have no idea what’s that all about.

34404: Fatal error: Float value cannot be converted to Int becauses
it is either infinite or NaN

It happened when I was trying to convert a float value to an integer. It’s a simple task for most circumstances.

            let currentSecText = String(format: "%.2d", Int(sec)%60)
let currentMinText = String(Int(sec)/60)//Error
let remainSecText = String(format: "%.2d", Int(rem)%60)//Error
let remainMinText = String(Int(rem)/60)
NaN (Not a Number): NaN stands for “Not a Number.” It’s a special value that represents something that is not a valid number. It’s like saying, “I don’t know what this number is.” For example, if you try to do something that doesn’t make sense with numbers, the result might be NaN. Like the result of dividing zero by zero.

NaN stands for “Not a Number.” It’s a special value that represents something that is not a valid number. It’s like saying, “I don’t know what this number is.” For example, if you try to do something that doesn’t make sense with numbers, the result might be NaN. Like dividing zero by zero.

Infinity: Infinity is like a super big number that goes on forever.

Solution: We need to add some prevention mechanism to prevent the value from being too big or makes no sense.

            guard !(rem.isNaN || rem.isInfinite) else {
return rem = 0
}

The cause: Since I couldn’t solve the problem on my own, I decided to seek help from Peter. When I approached him, he immediately pointed out that the issue might be occurring because the duration property and second property are indefinite before the player item gets loaded.

Optional solution: If the condition is not met (i.e., the AVPlayerItem is not ready to play), the code block inside the guard statement is executed, and the return statement is encountered, causing the playerObserver() function to exit immediately.


guard self.musicPlayer.currentItem?.status == .readyToPlay else {
return
}

Slider control(Set time)

    @IBAction func musicSlider(_ sender: UISlider) {
var val:Float = Float(sender.value)
let dur:Float = Float(self.musicPlayer.currentItem?.duration.seconds ?? 0)

val = dur*val
let time = CMTimeMake(value: Int64(val), timescale: 1)
musicPlayer.seek(to: time)
}

This one is slightly easier than the previous challenge. Just a few lines of code. Nothing to worry about! Let’s go.

First, we retrieve the value from the slider. But noted that the range of value is 0–1. We need it to be second.

So we multiply the value by the total duration of the music which can be obtained with self.musicPlayer.currentItem? .duration.second ?? 0 . And now our value is converted to seconds.

But seek function requires a CMTime in order to set the player time. So again, we are going to use CMTimeMake to convert the value from second to CMTime.

And that’s how you set the time for the music player.

Wait. You are still wondering what that ?? represents right? It’s called nil-coalescing. A different way to unwrap an “optional”.

How to prevent music from being reloaded

@IBAction func playButton(_ sender: UIButton) {
if musicPlayer.timeControlStatus == .playing {
// The music is already playing, so pause it and change the button icon.
sender.setBackgroundImage(UIImage(systemName: "play.circle"), for: .normal)
musicPlayer.pause()
} else {
// If the music player doesn't have a currently playing item, then load the music.
if musicPlayer.currentItem == nil {
let url = Bundle.main.url(forResource: songs[index], withExtension: "mp3")
let playItem = AVPlayerItem(url: url!)
musicPlayer.replaceCurrentItem(with: playItem)
}

// Now, the music player either has the item loaded or it already had an item.
// So, we can start or resume playing the music and change the button icon.
sender.setBackgroundImage(UIImage(systemName: "pause.circle"), for: .normal)
musicPlayer.play()
}

To prevent the music from being reloaded every time the user presses the play button, we can simply add an if statement to see if the music player already has a currently playing item. If it does, then there’s no need to reload the music, and you can simply resume playback. If it doesn’t have a currently playing item (meaning it’s empty), then you can load the new music according to the current index and start playing it.

How to prevent array index from out-of-range

    @IBAction func SwipeLeft(_ sender: Any) {
index = (index + 1) % 3
//logger.info("\(self.index)")
PageControl.currentPage = index
updateUI()
}

@IBAction func SwipeRight(_ sender: Any) {
index -= 1
index = index < 0 ? 2 : index

//logger.info("\(self.index)")
PageControl.currentPage = index
updateUI()
}

The index is used to switch between songs and UI elements, when it’s out of range. The app will likely crash or be unable to display certain information.

We can use % to loop over the index from 0 to 2. No matter how big the input value is.

We can also use the ternary operator to do so. The ternary operator is just a fancy name for the simplified if statement. It has a simple structure. Place a bool and an ? at the beginning and place the result of true and false separate by : .

//If the index is smaller than 0, the index is set to 2, else do nothing
index = index < 0 ? 2 : index

The tricks of view compositing

The view is being referenced. But its original position is out of bound
func scrollInitializer(){
//Scroll view section, willbe wrapped to function later
introView.frame = CGRect(x: 0, y: 0, width: 393, height: 235)
introScrollView.contentSize = CGSize(width: introScrollView.frame.width*2, height: introScrollView.frame.height)
introScrollView.isPagingEnabled = true


//Theme song view
themeSongView.layer.cornerRadius = 20

//Merge themeSongView and introView to scrollView
introScrollView.addSubview(introView)
introScrollView.addSubview(themeSongView)
view.addSubview(introScrollView)
}

Theme song view is actually a subview of the scroll view. I’ve noticed that it’s much more efficient to start by creating a subview in Interface Builder, and then simply add it to the scroll view using code. This approach gives me a better overview of your design before you start placing elements with pure code.

Overall Source code

import UIKit
import OSLog
import AVFoundation
import AVKit

class ViewController: UIViewController {
let logger = Logger()

// Arrays holding introduction contents and titles for different topics.
let introContents = ["...", "...", "..."]
let introTitles = ["Life is strange 1", "Life is strange 2", "Hollow knight"]

// Arrays holding poster images and songs for different topics.
let posters = ["...", "...", "..."]
let songs = ["01", "02", "03"]
var song = "02"

// Use 'index' to switch between different topics.
var index: Int = 0

// Views and layers for gradient backgrounds.
let gradientLayer = CAGradientLayer()
let gradientLayer02 = CAGradientLayer()

// Declare scrollView with code to prevent it from being killed.
var introScrollView = UIScrollView(frame: CGRect(x: 0, y: 575, width: 393, height: 235))

// Music player.
let musicPlayer = AVPlayer()

// Outlet variables connected to the Interface Builder.
// These are views and UI elements displayed on the screen.
@IBOutlet var poster: UIImageView!
@IBOutlet var introContent: UITextView!
@IBOutlet var introTitle: UILabel!
@IBOutlet var PageControl: UIPageControl!
@IBOutlet var gradientView: UIView!
@IBOutlet var themeSongView: UIView!
@IBOutlet var introView: UIView!
@IBOutlet var duration: UILabel!
@IBOutlet var currentTime: UILabel!
@IBOutlet var musicSliderView: UISlider!
@IBOutlet var backWardButton: UIButton!
@IBOutlet var forwardButton: UIButton!
@IBOutlet var playButtonView: UIButton!

// IBActions triggered by user interactions.
@IBAction func SwipeLeft(_ sender: Any) {
// Move to the next topic using modulo to loop back to the first topic when reaching the end.
index = (index + 1) % 3
PageControl.currentPage = index
updateUI()
}

@IBAction func SwipeRight(_ sender: Any) {
// Move to the previous topic using modulo to loop back to the last topic when reaching the beginning.
index -= 1
index = index < 0 ? 2 : index
PageControl.currentPage = index
updateUI()
}

@IBAction func buttonNext(_ sender: UIButton) {
// Move to the next topic using modulo to loop back to the first topic when reaching the end.
index = (index + 1) % 3
PageControl.currentPage = index
updateUI()
}

@IBAction func buttonPrevious(_ sender: UIButton) {
// Move to the previous topic using modulo to loop back to the last topic when reaching the beginning.
index -= 1
index = index < 0 ? 2 : index
PageControl.currentPage = index
updateUI()
}

@IBAction func segmentControl(_ sender: UISegmentedControl) {
// Change the visible section of the scrollView based on the selected segment.
if sender.selectedSegmentIndex == 0 {
let currentRect = introView.frame
introScrollView.scrollRectToVisible(currentRect, animated: true)
} else {
let currentRect = themeSongView.frame
introScrollView.scrollRectToVisible(currentRect, animated: true)
}
}

@IBAction func PageUpdate(_ sender: UIPageControl) {
// Update 'index' to match the currently selected page on the UIPageControl.
index = PageControl.currentPage
updateUI()
}

@IBAction func musicSlider(_ sender: UISlider) {
// Adjust the music player's playback time based on the position of the slider.
var val: Float = Float(sender.value)
let dur: Float = Float(self.musicPlayer.currentItem?.duration.seconds ?? 0)
val = dur * val
let time = CMTimeMake(value: Int64(val), timescale: 1)
musicPlayer.seek(to: time)
}

@IBAction func playButton(_ sender: UIButton) {
if musicPlayer.timeControlStatus == .playing {
// If music is playing, pause it and change the button icon.
sender.setBackgroundImage(UIImage(systemName: "play.circle"), for: .normal)
musicPlayer.pause()
} else {
// If music is paused or stopped, check if the current item is empty (nil).
// If it's empty, load the music for the current topic and start playing.
if musicPlayer.currentItem == nil {
let url = Bundle.main.url(forResource: songs[index], withExtension: "mp3")
let playItem = AVPlayerItem(url: url!)
musicPlayer.replaceCurrentItem(with: playItem)
}
// Change the button icon to pause and start playing the music.
sender.setBackgroundImage(UIImage(systemName: "pause.circle"), for: .normal)
musicPlayer.play()
}
}

// Function to add a gradient to the views.
func grad() {
// Add a gradient to 'gradientView'.
gradientLayer.frame = gradientView.bounds
gradientLayer.colors = [
UIColor.clear.cgColor,
UIColor.black.cgColor
]
gradientLayer.locations = [0, 0.3, 1]
gradientView.layer.addSublayer(gradientLayer)
}

func grad02() {
// Add a gradient to 'themeSongView'.
gradientLayer02.frame = themeSongView.bounds
gradientLayer02.colors = [
UIColor.darkGray.cgColor,
UIColor.black.cgColor,
UIColor.black.cgColor,
UIColor.darkGray.cgColor
]
gradientLayer02.locations = [0, 0.25, 0.75, 1]
themeSongView.layer.insertSublayer(gradientLayer02, at: 0)
}

// Function to update the UI when switching between topics.
func updateUI() {
// Update content, poster image, and music for the current topic.
let title: String = introTitles[index]
let content: String = introContents[index]
poster.image = UIImage(named: posters[index])
introTitle.text = title
introContent.text = content

// Load the music for the current topic.
let url = Bundle.main.url(forResource: songs[index], withExtension: "mp3")
let playItem = AVPlayerItem(url: url!)
musicPlayer.replaceCurrentItem(with: playItem)

// Reinitialize gradients for the views.
grad()
grad02()
}

// Function to initialize the scrollView and its subviews.
func scrollInitializer() {
// Set up the scrollView and add 'introView' and 'themeSongView' as subviews.
introView.frame = CGRect(x: 0, y: 0, width: 393, height: 235)
introScrollView.contentSize = CGSize(width: introScrollView.frame.width * 2, height: introScrollView.frame.height)
introScrollView.isPagingEnabled = true
introScrollView.addSubview(introView)
introScrollView.addSubview(themeSongView)
view.addSubview(introScrollView)
}

// Function to observe and update the music player's progress every 0.5 seconds.
func playerObserver() {
var dur: Float = 0.0
var sec: Float = 0.0
let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
musicPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { CMTime in
// Get the current duration and current time of the music player.
dur = Float(self.musicPlayer.currentItem?.duration.seconds ?? 0)
sec = Float(self.musicPlayer.currentTime().seconds)

// Calculate the remaining time and the music slider value (current time / duration).
var rem = (dur - sec)
let val = sec / dur

// Ensure that 'rem' is a finite value (not NaN or infinite).
// If it's not, set it to 0.
guard !(rem.isNaN || rem.isInfinite) else {
return rem = 0
}

// Update the music slider's value and labels showing the current time and remaining time.
self.musicSliderView.value = val
let currentSecText = String(format: "%.2d", Int(sec) % 60)
let currentMinText = String(Int(sec) / 60)
let remainSecText = String(format: "%.2d", Int(rem) % 60)
let remainMinText = String(Int(rem) / 60)
self.currentTime.text = currentMinText + ":" + currentSecText
self.duration.text = remainMinText + ":" + remainSecText
}
}

override func viewDidLoad() {
super.viewDidLoad()

// Update UI at the beginning.
updateUI()

// Initialize the scrollView and set up player observer.
scrollInitializer()
playerObserver()
}
}

Conclusion/ Git

I must admit, the process of making this app with only a few functionality is a lot harder than I thought. But I certainly learned a lot from it.

--

--