On-Demand Resources in tvOS Tutorial

Learn how to download resources in your tvOS apps upon demand – especially useful if you are trying to make your app fit within the 200MB limit! By Jawwad Ahmad.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Requesting Tags on Selection

Open VideoListViewController.swift and add the following property to VideoListViewController:

var currentVideoResourceRequest: NSBundleResourceRequest?

This property will store the NSBundleResourceRequest for the most recently requested video. Next, you’ll need to request this video when the user selects one of the videos in the collection view. Find didSelectVideoAt(_:) and replace its implementation with the following:

func didSelectVideoAt(_ indexPath: IndexPath) {
  // 1
  currentVideoResourceRequest?.progress.cancel()
  // 2
  guard var video = collectionViewDataSource
      .videoFor(indexPath: indexPath),
    let videoCategory = collectionViewDataSource
      .videoCategoryFor(indexPath: indexPath) else {
    return
  }
  // 3
  currentVideoResourceRequest = ResourceManager.shared
    .requestVideoWith(tag: video.videoName,
      onSuccess: { [weak self] in

      },
      onFailure: { [weak self] error in

      }
  )
}

The code breaks down as follows:

  1. If there’s a video request in progress, cancel that resource request and let the new video request take priority.
  2. This is the same code from the old version of didSelectVideoAt(_:); it’s how you find the video and category of the selected cell.
  3. Set currentVideoResourceRequest equal to a new NSBundleResourceRequest created by ResourceManager. The resource tag passed as a parameter is the name of the video; this is why you followed that strict naming scheme earlier.

The next step is to handle the failure case. Add the following method to the VideoListViewController extension:

func handleDownloadingError(_ error: NSError) {
  switch error.code{
  case NSBundleOnDemandResourceOutOfSpaceError:
    let message = "You don't have enough storage left to download this resource."
    let alert = UIAlertController(title: "Not Enough Space",
      message: message,
      preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "OK",
      style: .cancel, handler: nil))
    present(alert, animated: true, completion: nil)
  case NSBundleOnDemandResourceExceededMaximumSizeError:
    assert(false, "The bundle resource was too large.")
  case NSBundleOnDemandResourceInvalidTagError:
    assert(false, "The requested tag(s) (\(currentVideoResourceRequest?.tags ?? [""])) does not exist.")
  default:
    assert(false, error.description)
  }
}

handleDownloadingError(_:) handles all the possible errors from a resource request.

The main three error cases occur when either the device is out of storage, the resource is too large, or you’ve requested an invalid tag. If the device is out of storage, you alert the user of the problem. You’ll need to catch the other two issues before you deploy your app; at that point, it’s too late to make changes. So that they don’t go unnoticed in your testing phase (you are testing, right?), you crash the app with an error message.

The default assertion catches any other errors that could occur, such as network loss.

Note: Before releasing your app, you’ll probably want to handle these errors in a more user-friendly way.

Now, call your new method in the onFailure(_:) closure:

self?.handleDownloadingError(error as NSError)

In the onSuccess closure, add the following code to handle the successful download:

guard let currentVideoResourceRequest =
  self?.currentVideoResourceRequest else { return }
video.videoURL = currentVideoResourceRequest.bundle
  .url(forResource: video.videoName, withExtension: "mp4")
let viewController = PlayVideoViewController
  .instanceWith(video: video, videoCategory: videoCategory)
self?.navigationController?.pushViewController(viewController,
  animated: true)

Here you set the URL of the selected video to the downloaded resource within the requested bundle. Then, you present a new instance of PlayVideoViewController with the video as a parameter.

Run your app; select a video to play and you’ll notice there’s a bit of a delay before the video starts. This is because the video is being downloaded from the server — or in this case, from your computer.

To check that your app is using the on-demand bundle, open the debug navigator and select Disk; the Status column of your chosen video will now show “In Use”.

If you press Menu on your remote, you’ll notice the tag still shows as “In Use”. That’s not quite right, is it? The resource should change to “Downloaded” since you’re no longer using the resource. You’ll fix this in the next section.

Purging Content

Responsible management of your resources includes releasing them when you’re done. This involves two steps:

  1. Call endAccessingResources() on the NSBundleResourceRequest.
  2. Let the resource request deallocate.

The best time to let ODR know you no longer need the video is once the user’s finished watching the video. This happens when you dismiss PlayVideoViewController and VideoListViewController reappears on-screen.

Add the following method to VideoListViewController.swift:

override func viewWillAppear(_ animated: Bool) {
  super.viewWillAppear(animated)

  currentVideoResourceRequest?.endAccessingResources()
  currentVideoResourceRequest = nil
}

This code first checks that VideoListViewController reappears once you’ve dismissed a different view controller from the navigation stack. It then calls endAccessingResources() on the resource request and sets the property to nil, which lets the resource request deallocate.

Build and run your app; watch the Resource Tags menu as you play a video, then press Menu to go back. The Resource Tags menu now shows the requested resource as “Downloaded”. Perfect! You won’t delete the resource until the system needs the space. As long as the device has room, the resources will remain in storage.

Progress Reporting

At present, the user has no way to see the progress of the download. Is the video almost completely downloaded? Or is it not downloading at all?

In order to indicate the progress of the download, you’ll use a progress bar to observe the progress of the NSBundleResourceRequest.

Back in VideoListViewController.swift, add the following line to the beginning of didSelectVideoAt(_:):

progressView.isHidden = false

At the beginning of both the onSuccess and onFailure closures, add the following line — which does the exact opposite as the previous line:

self?.progressView.isHidden = true

This code shows the progress bar when the download begins, and hides it when the download ends.

Key-Value Observing

To connect the progress bar with the NSBundleResourceRequest, you need to use Key-Value Observing.

Open ResourceManager.swift and add the following to the top of the file, above the class declaration:

let progressObservingContext: UnsafeMutableRawPointer? = nil

Next, you need to change requestVideoWith(tag:onSuccess:onFailure:) to accept the progress observer as a parameter.

Replace the declaration of requestVideoWith(tag:onSuccess:onFailure:) with the following:

func requestVideoWith(tag: String,
  progressObserver: NSObject?,
  onSuccess: @escaping () -> Void,
  onFailure: @escaping (Error) -> Void) -> NSBundleResourceRequest {

This method now has a new progressObserver parameter that will make it easier to use KVO with your custom view controller and progress bar.

Within this method, add the following code before the return statement:

if let progressObserver = progressObserver {
  videoRequest.progress.addObserver(progressObserver,
    forKeyPath: "fractionCompleted",
    options: [.new, .initial],
    context: progressObservingContext)
}

Here, you add the argument as an observer to the request’s progress.

Just like you added the observer before the resource was loaded, you’ll need to remove the observer once it’s loaded. Add the code below to the beginning of the OperationQueue.main.addOperation block:

if let progressObserver = progressObserver {
  videoRequest.progress.removeObserver(progressObserver,
    forKeyPath: "fractionCompleted")
}

Xcode will respond with an error in VideoListViewController; this is because you changed the method signature of requestVideoWith(tag:onSuccess:onFailure) to requestVideoWith(tag:progressObserver:onSuccess:onFailure).

Open VideoListViewController.swift and change the line with the error to the following:

currentVideoResourceRequest = ResourceManager.shared
  .requestVideoWith(tag: video.videoName,
                    progressObserver: self,
    onSuccess: { [weak self] in
      ...
    },
    onFailure: { [weak self] error in
      ...
    }
)

The only change here is that you added the progressObserver parameter and passed self as the observer.

In order to respond to changes as the download progresses, you’ll need to implement observeValue(forKeyPath:of:change:context:) in the view controller — your observer.

Add the following method to VideoListViewController:

override func observeValue(forKeyPath keyPath: String?,
    of object: Any?,
    change: [NSKeyValueChangeKey : Any]?,
    context: UnsafeMutableRawPointer?) {

  if context == progressObservingContext
    && keyPath == "fractionCompleted" {

      OperationQueue.main.addOperation {
        self.progressView.progress
          = Float((object as! Progress).fractionCompleted)
      }
  }
}

When the value of the download’s progress changes, you reflect this change in the progress bar on the main thread.

Build and run your app; select a video to watch and you’ll see the progress bar at the top-right corner of the screen. Once the progress bar has filled, it will disappear and the video will play: