macOS programming · · 15 min read

Learn macOS Development: Develop a Music App with Audio Playback and File Upload Features

Learn macOS Development: Develop a Music App with Audio Playback and File Upload Features

Welcome to my second tutorial of the macOS app development! If you have not read my first tutorial on macOS app development, it may be helpful for you to check it out first as I will not go into some of the stuff, which were already mentioned there. In this tutorial, we will be looking into custom views, audio playback and one of the most commonly used feature: File Upload!.

Pre-requisites

  • Basic understanding of Swift-programming (advantage)
  • Xcode 9 installed
  • Passion to build a macOS app

What will you learn?

  • Basic concepts of macOS development
  • How to integrate AudioPlayer with macOS app to play audio files
  • How to create a File Upload mechanic
  • How to create Reusable Custom Views
  • Swift 4 syntax
  • Auto layout

What are we going to build?

Have you ever wondered why audio players like Spotify or iTunes don’t have a repeat X number of times function? Today, we will be delivering a working product that allows us to:

  1. Upload a .mp3 format audio file
  2. Set number of repeats
  3. Play and repeat as many times as set

MusicRepeaterDemo

As you can see from the demo above, the app is very simple. The user will first be presented with a home screen with instruction saying, “Click here to upload a music file (.mp3 files only).” Next, the user can click to upload any mp3 format audio files to the app. It will then present the next screen, which is a screen for the user to pick the number of repeats. Once the number of repeats is confirmed, the user are able to hit Next. They will be presented with the Playing screen which will be shown till the app has finished playing the music playback.

Enough of talking, let’s get started!

Creating your macOS project

First, let’s launch Xcode 9 and create a new macOS app project named MusicRepeater. Choose macOS and Cocoa App under Application.

macOS new project

You can follow the settings I put in the next page as follow, then choose a directory of your choice for the project files.

macos-project-setting

Now that you have the project setup, let’s rename the default ViewController as HomeViewController using Xcode’s Refactor function. Head over to ViewController.swift, highlight the class name then go to Toolbar -> Editor -> Refactor -> Rename and let’s call it MRHomeViewController, this name is more explicit. MR stands for the initials of the app we are making. As a software developers, we will work with projects that may involve many other frameworks, putting a prefix (i.e. like initials of our app name) helps us identify which viewcontroller is localized to our app.

macos-3

We will be using NSViewController to create 3 different views which we will walk through in the next few sections:

  1. Upload View
  2. Set Repeats View
  3. Playing View

Building the Upload View

You can download the upload image icon I use here. Once you have the asset, drag it into Assets.xcassets and rename it to imageUpload. Also, move the asset to 2x.

macos-4-image-asset

Next, we will create the custom view class & nib. In a nutshell, NSNib is like an unpacked object of the XML Interface Builder file (XIB). Now, do not be confused with XIB & NIB. NIB works closely with the XIB file to re-establish all related IBOutlet connections so that when you create a custom view object from the Nib, all the connections are retained.

Adding the Upload View Class

Now, let’s start by creating our first custom view called MRUploadView. Go to File -> New -> File -> Cocoa Class and ensure that the Subclass is set to NSView. To keep our project directory more organized, create a new group in our project navigator and name it Custom Views.

macos-upload-view

Let’s also move the below files into a new group named Resources :

  1. MusicRepeater.entitlements
  2. Assets.xcassets
  3. Info.plist
  4. Main.storyboard

macos-new-group

We can also shift AppDelegate.swift to the top and you should have a more tidied up project navigator like this!

macos-tidy-group

Creating the Upload View in XIB

Now that we have the View class, we will now create a XIB file, so that we can edit the view easily using Interface Builder. To better organize the project, let’s put all our custom view related files in the newly created Custom Views folder. So go ahead, right click on Custom Views folder and go to New File. Go all the way down and find View. Name it the same as the custom view class MRUploadView.Xib.

macos-view

I will skip explaining how to drag new view objects into the view as this was discussed quite broadly in my previous tutorial. For MRUploadView, just drag a Button and a Label. It should look like this:

macos-view-design

Apparently, the XIB view needs a class owner, so set the class as MRUploadView, and we can be off to load it into MRHomeViewController! Be sure to get familiarised with all these steps as I will be skipping the details for the next 2 custom views.

macos-view-class

Hooking the Xib with the Class

Phew! You did a great job coming this far. We are ready to start coding! Let’s start by adding two IBOutlets to our UploadView.

import Cocoa

class MRUploadView: NSView {

    @IBOutlet weak var uploadButton: NSButton!
    @IBOutlet weak var staticLabel: NSTextField!
    
}

Drag the two IBOutlets and connect them in our Xib.

macos-hook-up

Load it into the View Controller

Now that we have our view wired up, let’s load it into MRHomeViewController.

import Cocoa

class MRHomeViewController: NSViewController {

    lazy var uploadView: MRUploadView = {
        //1
        let nib = NSNib(
            nibNamed: NSNib.Name(rawValue: "MRUploadView"),
            bundle: nil
        )
        
        //2
        var objects: NSArray?
        nib?.instantiate(withOwner: self, topLevelObjects: &objects)
        
        //3
        if let objects = objects,
            let uploadView = objects
            .filter( { $0 is MRUploadView } )
            .first as? MRUploadView {
            return uploadView
        }
        
        //4
        return MRUploadView()
    }()    
}

There’re quite a number of actions taken place here:

  1. Here we are initialising a new NSNib object from our Xib file. As our Xib file is in the main bundle, there is no need to specify any bundle here.
  2. Next, we declare an array of objects to store the view objects contained in the Xib file. Taking a look at the instantiate method, we set the nib’s owner as self which is our MRHomeViewController, and there is another unfamiliar parameter that the method is taking in, its call AutoreleasingUnsafeMutablePointer. One good thing about such parameter is that once the method ends, the reference to the objects variable will be gone, freeing up memory. We have to prefix our parameter with & because it’s also an inout parameter, whenever our objects is modified, it will affect the passed-in parameter too.
  3. Phew, that was quite a mouthful! Now, here’s something lighter, we loop through the objects found in the Xib and search for the MRUploadView object. Once found, return it to the uploadView variable.
  4. If nothing is found, we will just create a new MRUploadView object.

Now we are ready to load our view. Insert this code after the uploadView initialisation:

    override func viewDidLoad() {
        super.viewDidLoad()
        
        showStep1()
    }
    
    fileprivate func showStep1() {
        //Init Upload View
        uploadView.frame = self.view.frame
        self.view.addSubview(uploadView)
    }

Upon calling uploadView, we have actually initialised the view object, so here we set the frame to be the same as our window size and add it as the subview.

Now, let’s try running the app! Oops, you may have actually come across a weird error like:

Verify the value of the CODE_SIGN_ENTITLEMENTS build setting for target "MusicRepeater" and build configuration "Debug" is correct and that the file exists on disk.

When we organized our files just now, Xcode actually created a new folder named Resources and placed the files inside the folder. This is the reason why the error above appeared.

Let’s fix it. First, go to the Project’s Build Settings, search for entitlements and add the path to the Resources. If you also encounter issue with Info.plist, just click the find file and navigate to the file itself to fix it.

macos-build-setting

Try running the app again and you should see your upload view!

Coding the Upload Logic

Now, we are going to awaken the uploading mechanic of our view! Let’s hook up an action function for our upload button. Open up MRUploadView.swift and while holding down option key, click MRUploadView.xib and you should see your xib file opened at the right window. This shortcut will come in handly as you develop your applications on Xcode 🙂

@IBAction func onUploadFile(_ sender: Any) { }

You should have created a method like this after the IBOutlet connection. We want to open up our system file uploader when this button is clicked. So here, let’s use our simple Delegate Pattern again to establish a communication between our Upload View and our View Controller.

We will declare a UploadViewDelegate with a function call didTapUploadFile. Next, we will declare a delegate object to hold the delegate and call the delegate function onFileUpload. Your code should look like this:

import Cocoa

protocol UploadViewDelegate {
    func didTapUploadFile()
}

class UploadView: NSView {
    
    @IBOutlet weak var uploadButton: NSButton!
    @IBOutlet weak var staticLabel: NSTextField!
    
    var delegate: UploadViewDelegate?

    @IBAction func onUploadFile(_ sender: Any) {
        self.delegate?.didTapUploadFile()
    }
}

Try doing a build project, everything should still work as normal.

Introducing NSOpenPanel

Here, we will introduce another new member of macOS AppKit called NSOpenPanel. In simple words, calling the methods from this class will trigger a call to system file panel.

Since we have created a delegate in the Upload View, we need to set our view’s delegate first. So go ahead and add this line of code:

fileprivate func showStep1() {
    //Init Upload View
    uploadView.frame = self.view.frame
    
    //Add this line of code
    uploadView.delegate = self
    
    self.view.addSubview(uploadView)
}
Note: Here, you may encounter an error where Xcode wants you to force unwrap the delegate. This is because we have not set our view controller to implement to the protocol object. We will do it next.

Go ahead and implement an extension for MRHomeViewController. We use extension to adopt a protocol for a reason. It helps us maintain code integrity, separating feature-specific chunk of codes. In the code below, we provide the implementation of didTapUploadFile() method that makes use of NSOpenPanel:

extension MRHomeViewController: UploadViewDelegate {
    func didTapUploadFile() {
        //1
        let panel = NSOpenPanel()
        panel.begin { (response) in
            //2
            guard
                response == NSApplication.ModalResponse.OK,
                let document = panel.urls.first,
                document.absoluteString.contains(".mp3")
                else {
                    print("Invalid MP3 File!")
                    return
            }
            
            print("Valid MP3 File!")
        }
    }
}
  1. Here we initialised a new NSOpenPanel object. We then call its begin method which is a superclass method from NSSavePanel. This will present a modal view of our system file panel to load in a .mp3 file.

  2. The response parameter of the closure is an enum of NSApplication.ModalResponse. We used a guard statement to handle the response and verify if the `.mp3` file is valid.

We will connect the dots later as we implement of next step. Go ahead, run the app and try loading your non-mp3 and mp3 files! Good job so far!

From here and onwards, I will go rather fast as I will not be covering in-depth on stuffs we have covered in the previous tutorial. If you are stuck at any point, try to get the finished project at the end of the tutorial for reference. Happy Coding!

Creating our Repeater View

Welcome to the second part of our MusicRepeater App! We are now going to create our repeater view, users will be brought to this view once a valid .mp3 audio file is uploaded to the app.

Creating the Repeater View Class and Xib

Similarly, we will first create our RepeaterView class, and then create a MRRepeaterView Xib file. Your MRRepeaterView should have the following components:

  1. Title Label
  2. Add Button (+)
  3. Subtract Button (-)
  4. Number Label
  5. Next Button

And, it should look like this after you lay out the view:

macos-repeater-design

We do not need to modify everything programmatically, so just connect the following IBOutlets and IBActions will do:

  1. @IBOutlet weak var numberLabel: NSTextField!
  2. @IBAction func onAdd(_ sender: Any)
  3. @IBAction func onSubtract(_ sender: Any)
  4. @IBAction func didTapNext(_ sender: Any)
Remember to put everything inside the Custom Views folder!

We will also want to remove the draw method and replace it with awakeFromNib method. Everytime when we unarchive our view from the xib file, a message will be sent to this class. Then this method will be called, so it will be a good place for us to do some initialisation later on.

Your code should now look like this:

import Cocoa

class MRRepeaterView: NSView {
    @IBOutlet weak var numberLabel: NSTextField!
    
    override func awakeFromNib() {}
    
    @IBAction func onAdd(_ sender: Any) {}
    @IBAction func onSubtract(_ sender: Any) {}
    @IBAction func didTapNext(_ sender: Any) {}
}

Coding the Repeater’s Logic

Next, let’s do some initial setup and put them in awakeFromNib. The label should always show 0 when it is loaded. We also want to make sure our label cannot be interacted. So implement the awakeFromNib() method like this:

override func awakeFromNib() {
    numberLabel.isEditable = false
    numberLabel.isSelectable = false
    numberLabel.stringValue = "0"
}

Next, we are going to add the logic of the Add and Subtract buttons. Let’s first declare a local variable to keep track of the currentCount.

var currentCount = 0

Whenever the Add or Subtract button is clicked, we need to update the label to reflect the current value. Here we use didSet to handle the update. Update your code like this:

var currentCount = 0 {
    didSet {
        numberLabel.stringValue = "\(currentCount)"
    }
}

@IBAction func onAdd(_ sender: Any) {
    currentCount += 1
}

@IBAction func onSubtract(_ sender: Any) {
    currentCount -= 1
}

Interacting with the Repeater View

Now that we have our Repeater View ready, let’s go back to our view controller to present it! Similar to what we have done before, we have to create a variable to hold the repeater view. Insert the following code in MRHomeViewController to declare the variable:

    var repeaterView: MRRepeaterView = {
        //1
        let nib = NSNib(
            nibNamed: NSNib.Name(rawValue: "MRRepeaterView"),
            bundle: nil
        )
        
        //2
        var objects: NSArray?
        nib?.instantiate(withOwner: self, topLevelObjects: &objects)
        
        //3
        if let objects = objects,
            let repeaterView = objects
                .filter( { $0 is MRRepeaterView } )
                .first as? MRRepeaterView {
            return repeaterView
        }
        
        //4
        return MRRepeaterView()
    }()

Next, we need to hide the upload view. Therefore, create a showStep2() method:

fileprivate func showStep2() {
    //Init Repeater View
    repeaterView.frame = self.view.frame
        
    //Hide Upload View
    uploadView.isHidden = true
        
    self.view.addSubview(repeaterView)
}

The last thing we need to do for here is to call the above function when the .mp3 file is valid. Before that, let’s first add a variable named musicFilePath to hold the file path of the .mp3 file:

var musicFilePath: String?

Next, update the didTapUploadFile() method to replace the line of print("Valid MP3 File!") with showStep2(). Your method should look like this after the update:

func didTapUploadFile() {
    let panel = NSOpenPanel()
    panel.begin { [weak self] response in
        guard
            response == NSApplication.ModalResponse.OK,
            let document = panel.urls.first,
            document.absoluteString.contains(".mp3")
            else {
                print("Invalid MP3 File!")
                return
        }
        
        self?.musicFilePath = document.absoluteString
        self?.showStep2()
    }
}

Because we will be calling self here, it is recommend to use weak or unowned here to prevent Strong Reference Cycle.

Now, go ahead and run the app, upload a valid .mp3 file. You should see the second screen!

OOPS!

If you try the Add and Subtract features, you may have noticed some UI issue, and that the number goes below 0.

macos-ui

Logically, the repeat feature won’t work by having a negative value. We will fix the UI at the end of the tutorial. Now let’s fix the negative value first.

Extending the Int Type

One of the most beautiful things that Swift offers is the ability to easily extend the existing type (e.g. Int), even for the types provided by the iOS SDK. We are going to add an add() function and a subtract() function for the built-in Int type.

In the project navigator, right-click on MusicRepeater and create a new Swift File. Name it Int+Extensions and paste the following code in:

extension Int {
    mutating func subtract() {
        self = (self <= 0) ? 0 : self - 1
    }
    
    mutating func add() {
        self += 1
    }
}

As we need to mutate the properties of our Int, we will need to add the mutating keyword. For the rest of the code, it is pretty straightforward. In particular, the subtract method performs an additional checking to verify if the existing number is valid for subtraction.

Now let's apply our new Int extension. Update both onAdd and onSubtract methods like this:

@IBAction func onAdd(_ sender: Any) {
    currentCount.add()
}
    
@IBAction func onSubtract(_ sender: Any) {
    currentCount.subtract()
}

Run the app and it should work as expected!

Implementing the Next Function

Just like what we did previously, we will be adding a delegate to MRRepeaterView, so that MRHomeViewController knows when to show the PlayingView.

Insert the following code in the MRRepeaterView class:

protocol MRRepeaterViewDelegate {
    func didTapNext()
}

Next, declare a delegate variable:

var delegate: MRRepeaterViewDelegate?

And, then update the didTapNext action method like this:

@IBAction func onNext(_ sender: Any) {
    self.delegate?.didTapNext()
}

Now go back to MRHomeViewController, extend the class and implement our delegate:

extension MRHomeViewController: MRRepeaterViewDelegate {
    func didTapNext() {}
}

We will also need to set our delegate in the showStep2() function.

fileprivate func showStep2() {
    //Init Repeater View
    repeaterView.frame = self.view.frame
    
    //Add this line of code
    repeaterView.delegate = self
    repeaterView.isHidden = false
    
    //Hide Upload View
    uploadView.isHidden = true
    
    self.view.addSubview(repeaterView)
}

Now we are ready to go to our final section. Give yourself a pat for coming this far! Just one more lap to go!

Creating the Playing View

Like before, add a new view and xib named MRPlayingView. The view will look like this:

music-app-playing-view

Basically, it has the following UI components:

  • ImageView (The image can be downloaded here)
  • Restart Button
  • Playing Label

Similar to what we have done in the previous section, create a new class called MRPlayingView and update it with the following code:

import Cocoa

//1
protocol MRPlayingViewDelegate {
    func didTapRestart()
}

class MRPlayingView: NSView {
    //2
    @IBOutlet weak var staticLabel: NSTextField!
    @IBOutlet weak var restartButton: NSButtonCell!
    
    //3
    var delegate: MRPlayingViewDelegate?
    
    //4
    @IBAction func onRestart(_ sender: Any) {
        self.delegate?.didTapRestart()
    }
}

This code above is pretty straightforward and similar to that we have implemented before. We define the MRPlayingViewDelegate to handle communication between controllers

Working with the Playing View

Same as before, go back to MRHomeViewController and modify it to declared MRPlayingView:

var playingView: MRPlayingView = {
    //1
    let nib = NSNib(
        nibNamed: NSNib.Name(rawValue: "MRPlayingView"),
        bundle: nil
    )
    
    //2
    var objects: NSArray?
    nib?.instantiate(withOwner: self, topLevelObjects: &objects)
    
    //3
    if let objects = objects,
        let playingView = objects
            .filter( { $0 is MRPlayingView } )
            .first as? MRPlayingView {
        return playingView
    }
    
    //4
    return MRPlayingView()
}()

Next, create the showStep3() method like this:

    fileprivate func showStep3() {
        //Init Playing View
        playingView.frame = self.view.frame
        playingView.delegate = self
        
        //Hide Repeater View
        repeaterView.isHidden = true
        
        playingView.staticLabel.isHidden = false
        playingView.staticLabel.stringValue = "Playing"
        
        self.view.addSubview(playingView)
    }

We will also need to create the restart() method, so that users can be brought back to choose another song:

    fileprivate func restart() {
        uploadView.isHidden = false
        repeaterView.removeFromSuperview()
        playingView.removeFromSuperview()
    }

As we are adding our views in our Step2 and Step3 functions, we should call removeFromSuperview() instead of hiding it.

Now, we can extend and implement the delegate method:

extension MRHomeViewController: MRPlayingViewDelegate {
    func didTapRestart() {
        restart()
    }
}

We also need to implement our didTapNext() method in MRHomeViewController.swift:

extension MRHomeViewController: MRRepeaterViewDelegate {
    func didTapNext() {
        showStep3()
    }
}

You can now run the app and see the progress you have achieved so far!

We are missing one very important puzzle piece here, the AudioPlayer! Without it, the app can't perform audio playback.

Integrating AudioPlayer

We will use an open source library called AudioPlayer which wraps over the built-in AVPlayer to manage audio playback. You can install the library up to your likings, personally I prefer to use Carthage. Of course, you can also use CocoaPods. For Carthage installation, just add github "delannoyk/AudioPlayer" to a new CartFile.private and run carthage update. Then, drag the framework in and add a Copy Files phase.

macos-copy-files

Okay, here is the list of things you need to do in order to use AudioPlayer for audio playback.

  1. Create an AudioItem instance that contains all the data for audio playback
  2. Key-value (AudioPlayer.AudioQuality-URL)
  3. Create an AudioPlayer instance
  4. Adopt the AudioPlayerDelegate protocol to manage its state

Let's begin the implementation. First, insert import AudioPlayer at the beginning of MRHomeViewController.swift and create an AudioPlayer instance:

var audioPlayer = AudioPlayer()

Next, we need to have a method called PlayMusic and update the showStep3() method like this:

fileprivate func showStep3() {
    //Init Playing View
    playingView.frame = self.view.frame
    playingView.delegate = self
    
    //Hide Repeater View
    repeaterView.isHidden = true
    
    playingView.staticLabel.isHidden = false
    playingView.staticLabel.stringValue = "Playing"
    
    self.view.addSubview(playingView)
    
    //1
    var playlist = [AudioItem]()
    audioPlayer.delegate = self
    
    //2
    if let filename = musicFilePath, let url = URL(string: filename) {
        for _ in 0..<repeaterView.currentCount {
            let item = AudioItem(mediumQualitySoundURL: url)
            playlist.append(item!)
        }
        
        playMusic(playlist)
    }
}

//3
func playMusic(_ playlist: [AudioItem]) {
    audioPlayer.play(items: playlist)
}

fileprivate func restart() {
    uploadView.isHidden = false
    repeaterView.removeFromSuperview()
    playingView.removeFromSuperview()
    
    //4
    audioPlayer.stop()
}

First, we need to create an array of AudioItem because we will be passing in this array to our audioPlayer to loop through and play. We also set our audioPlayer's delegate. Next, we validate that we have a valid URL. Based on the count that was set in step 2, we create a new AudioItem object and add it into an array. After the loop, we call playMusic with the play list. The AVPlayer will handle all the playing processes. As you can see, what you need is to prepare the list of audio objects.

For the restart method, which is called when someone clicks the Restart button, we stop the audio player to interrupt the music playback.

Finally, we implement AudioPlayerDelegate method as below to update the static label when the audio has reached the end of the playlist.

//5
extension MRHomeViewController: AudioPlayerDelegate {
    func audioPlayer(_ audioPlayer: AudioPlayer, didChangeStateFrom from: AudioPlayerState, to state: AudioPlayerState) {
        if state == .stopped {
            playingView.staticLabel.stringValue = "Stopped"
        }
    }
}

That's it! Now go ahead and enjoy your MusicRepeater App!

Wrapping Up.

Thank you for following me till the end of my second macOS tutorial! We've achieved a lot of stuff in this tutorial. You should learn how to build a macOS app from scratch and understand how to use various APIs for music playback and file upload. I really hope you enjoy the tutorial. If you have any tutorial ideas that you want to request, feel free to drop me a comment or message and I will try my best to meet your needs.

For the sample project, you can download the full source code on GitHub.

Read next