Ben Dodson

Freelance iOS, macOS, Apple Watch, and Apple TV Developer

Side Project: Back Seat Shuffle

Want to keep up to date? Sign up to my free newsletter which will give you exclusive updates on all of my projects along with early access to future apps.

This is part of a series of blog posts in which I showcase some of the side projects I work on for my own use. As with all of my side projects, I’m not focused on perfect code or UI; it just needs to run!

If I’m going on a long drive with my two young children, I’ll load up an iPad with some videos and stick it in a pouch on the back of a seat to keep them entertained. Initially this started as a few films and a couple of their TV series on a USB-C stick but I’ve gradually started putting a few shows directly onto the iPad so they can be played via VLC. Why? Well, when using an external drive you’re limited to using the Files app which uses Quick View for video playback; this is fine for a film but for TV you have to go and start a new episode after the previous one finishes (and that involves my wife precariously leaning into the back without a seatbelt which isn’t ideal). I moved to using VLC for TV shows as they then play sequentially avoiding that problem but it can’t play from an external drive so I have to put things directly onto the limited storage of the device.

For a couple of weeks I’ve been toying with the idea of whether I could build a better app, one that would let me:

  • Plug in an external drive
  • Show each series with a nice image
  • Play episodes randomly without needing to copy the video to the device

After a 3 hour drive to visit my mother, the priority for this has now increased exponentially 😂

To begin with, I needed to know if it is even possible to view external files within an app on iOS. It is, and has been since the introduction of UIDocumentPickerViewController in iOS 13 however the documentation left me a little confused:

Both the open and export operations grant access to documents outside your app’s sandbox. This access gives users an unprecedented amount of flexibility when working with their documents. However, it also adds a layer of complexity to your file handling. External documents have the following additional requirements:

  • The open and move operations provide security-scoped URLs for all external documents. Call the startAccessingSecurityScopedResource() method to access or bookmark these documents, and the stopAccessingSecurityScopedResource() method to release them. If you’re using a UIDocumentsubclass to manage your document, it automatically manages the security-scoped URL for you.
  • Always use file coordinators (see NSFileCoordinator) to read and write to external documents.
  • Always use a file presenter (see NSFilePresenter) when displaying the contents of an external document.
  • Don’t save URLs that the open and move operations provide. You can, however, save a bookmark to these URLs after calling startAccessingSecurityScopedResource() to ensure you have access. Call the bookmarkData(options:includingResourceValuesForKeys:relativeTo:) method and pass in the withSecurityScope option, creating a bookmark that contains a security-scoped URL.

External files can only be accessed via a security-scoped URL and all of the tutorials I’d seen online relating to this were demonstrating how you could access a file and then copy it locally before removing that scope. I was therefore unsure how it would work in terms of streaming video (as it would go out of scope and lose security clearance) nor if I’d be able to retain access after displaying a directory and then wanting to start playback.

It turns out that it is all possible using a system known as “bookmarks”. In practice, a user will be shown their external drive in an OS controlled modal view and can select a folder, the URL of which is returned to my app. I then call the “start accessing security scoped resource” and convert that URL to a bookmark which is stored locally on my device and then close the security scoped resource. That bookmark can be used at any point to gain access to the drive (so long as it hasn’t been disconnected in which case the bookmark tells the app it is “stale” and therefore no longer working) and you can then interact with the URL the bookmark provides in the same way as you would with a local file.

func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
    guard let url = urls.first else { return }

    // make sure we stop accessing the resource once we exit scope (which will be as soon as the video starts playing)
    defer { url.stopAccessingSecurityScopedResource() }

    // we don't care about the return value for this as we'll try to create a bookmark anyway
    _ = url.startAccessingSecurityScopedResource()

    // store the bookmark data locally or silently fail
    bookmark = try? url.bookmarkData()

    // try to play the video; if there is an error, display an alert
    do {
        try playVideos()
    } catch {
        let controller = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert)
        controller.addAction(UIAlertAction(title: "OK", style: .default))
        present(controller, animated: true)
    }
}

private func playVideos() throws {
    guard let bookmark else { return }

    // get the local url from our bookmark; if the bookmark is stale (i.e. access has expired), then return
    var stale = false
    let directoryUrl = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale)
    let path = directoryUrl.path
    guard !stale else {
        throw BSSError.staleBookmark
    }

    // get the contents of the folder; only return mp4 and mkv files; if no files, throw an error
    let contents = try FileManager.default.contentsOfDirectory(atPath: path)
    let urls = contents.filter({ $0.hasSuffix("mp4") || $0.hasSuffix("mkv") }).map({ URL(filePath: path + "/" + $0) })
    guard urls.count > 0 else {
        throw BSSError.noFiles
    }

    // present the video player with the videos in a random order
    presentPlayer(urls.shuffled())
}

private func presentPlayer(_ urls: [URL]) {
    // set the audio session so video audio is heard even if device is muted
    try? AVAudioSession.sharedInstance().setCategory(.playback)

    // create a queue of player items from the provided urls
    let items = urls.map { AVPlayerItem(url: $0) }
    player = AVQueuePlayer(items: items)

    // present the player
    let playerController = AVPlayerViewController()
    playerController.player = player
    present(playerController, animated: true) {
        self.player?.play()
    }
}

This would also work in other contexts such as local files or even cloud-based services that work with the Files app such as iCloud or Dropbox.

I had originally planned on reading the contents of the USB stick and using a single .jpg file in each directory to render a nice thumbnail. In the end I abandoned that as it would have meant building the whole interface when in fact it works perfectly well just using UIDocumentPickerViewController to pick the show I’m interested in:

Selecting a directory of videos in Back Seat Shuffle

In the end the only extra code I added was to strip out any files that were not in the .mp4 or .mkv format and to have it automatically return to the file selection screen once the full queue of randomised videos had finished.

Whilst I could potentially put it on the App Store, this is one of those weird edge cases that likely wouldn’t get through App Review as they’ll look at it and say “this is just the Files app” and completely miss the point. As this would be a free app, it’s not worth the hassle of doing screenshots, App Store description, etc, only to have it be rejected by App Review.

The full app code is available on GitHub.

Postmortem of the launch of a Top 10 Paid iOS App » « Return to Dark Tower Assistant

Want to keep up to date? Sign up to my free newsletter which will give you exclusive updates on all of my projects along with early access to future apps.