DEV Community

Creating an Apple TV app with SwiftUI

In 2019, Apple launched its new UI paradigm SwiftUI, which is meant to replace the existing Interface Builder. It lets us create apps for all Apple platforms, including Apple TV's tvOS.

In this tutorial, we'll see how we can build a tvOS app that loads HLS streams from XML/MRSS files, displays them to the user for selection and plays back the selected stream. The streams will be specified in an .xcconfig configuration file, and either bundled at buildtime or downloaded at runtime depending on if local files or http(s):// URL:s are specified.

Tools we'll be using include Xcode and the CocoaPods package SWXMLHash, for parsing our XML input.

Video selection menu

Being a relatively new framework, the number of learning resources for SwiftUI are still smaller than for Interface Builder, and the ones that are available tend to focus on the iOS platform. Fortunately though, there are very few things which differ when using SwiftUI: UI elements and user interactions are automatically suited to the platform we're building for.

If you're new to Swift, I recommend following along with the playground in Apple's tutorial. To learn SwiftUI, Apple has an excellent tutorial project, which goes through many important features. For understanding this tutorial though, the first chapter, SwiftUI Essentials, should suffice. The guide is for iOS, but I was able to follow the steps for tvOS and get a working app, with only minor changes.

Although I will include some important snippets of code in this blog post, I recommend that you clone the project to get the full picture. As with many of our cool projects here at Eyevinn, the code is open source, so feel free to fork and play around with it!

Setting up

We start by creating an Xcode project for the app, making sure we select tvOS as our platform. I've used the project name EyevinnTV.

We also set up SWXMLHash in the same project folder by following these instructions. If you're using an M1 Mac, it may be easier installing CocoaPods through Brew than RubyGems (at least it was for me).

To open the project together with the SWXMLHash dependency, we use the .xcworkspace file created after running pod install rather than the .xcodeproj.

Building the ContentView

SwiftUI is all about different nested Views, and the main View in our app is the ContentView, which is called in the EyevinnTVApp.swift file. The EyevinnTVApp struct in this file conforms to the App protocol, and is preceded by the @main attribute, indicating that this is the entry point into our app.

EyevinnTVApp

Besides the automatically generated ContentView, we also create a VideoItemObject.swift file, and VideoView.swift file. The former will contain a simple Class that represents our video objects:

class VideoItem: Identifiable {
    var title = ""
    var id = ""
    var videoUrl = ""
    var thumbnailUrl = ""
}  
Enter fullscreen mode Exit fullscreen mode

Note that it should conform to the Identifiable protocol, as this will let us iterate over an array of VideoItem with ForEach loops.

In ContentView, we import SWXMLHash, and define a class videoUrlObject that conforms to ObservableObject:

class videoUrlObject: ObservableObject {
    @Published var currentVideoUrl = ""
    @Published var currentVideoTitle = ""
}
Enter fullscreen mode Exit fullscreen mode

Conforming to ObservableObject in the parent View is important, as this will let us pass in the currentVideoUrl with the @Published attribute to its child View VideoView, and synchronize changes to it by making it an ObservedObject:

@ObservedObject var currentVideoUrlObject: videoUrlObject
Enter fullscreen mode Exit fullscreen mode

In ContentView we create two state arrays of VideoItem objects, to which we will push the video items after parsing them. We also initialize our videoUrlObject with the @StateObject attribute:

    @State var videoItems = [VideoItem]()
    @State var liveVideoItems = [VideoItem]()
    @StateObject var currentVideoUrlObject = videoUrlObject()
Enter fullscreen mode Exit fullscreen mode

In the body of ContentView, we nest the different child Views that make up our UI; a main NavigationView, with either the video selection menu or VideoView being rendered, depending on whether the currentVideoUrl variable is set to en empty string (default) or the URL of one of the video items.

In the video selection menu, we display two carousels of videos, one intended for VOD content, and the other for live content. Each video object is rendered as a Button View, displaying its thumbnail (as an AsyncImage) and title. When clicking the Button, the currentVideoUrl property of the currentVideoUrlObject is set to the videoUrl of the corresponding VideoItem; this triggers a reload of the content inside our Navigation View, and the VideoView is rendered in place of the video selection menu.

VideoView

Building the VideoView

Besides creating the ObservedObject, we also import AVKit to create an instance of the VideoPlayer View. On creation, we use the onAppear() hook to load the currentVideoUrl of the ObservedObject. It's also essential that we set a frame size of 1920X1080 points; this lets the player know that we're displaying the video in fullscreen. If we don't, no playback controls will be displayed!

We also override the default behavior of pressing the Back/ Menu button on the remote, to set the value of the currentVideoUrl property to an empty string. This will trigger a reload, replacing the VideoView with the video selection menu in our ContentView.

Setting up the build configuration

In a SwiftUI app, necessary project files are bundled at buildtime. This means that we can include our lists of videos as XML files, which will be available at runtime through Bundle.main.path like this:

Bundle.main.path(forResource: myVideos, ofType: "xml")
Enter fullscreen mode Exit fullscreen mode

In this project, we also want to support input in the form of an http:// or https:// URL, or a file:// URL that points to an XML file somewhere on the compiling computer, which we make available at runtime.

To enable this, we'll create an Config.xcconfig build configuration file that sets the values of keys in a dictionary:

VOD_XML = https:\/\/testcontent.mrss.eyevinn.technology/
LIVE_XML = liveTestContentMrss
Enter fullscreen mode Exit fullscreen mode

Notice that we're escaping the double backslashes in URL:s. This is because "//" signifies the start of a comment in .xcconfig files, which is not what we want! The backslashes still become part of the value though, so we'll need to remove them later to get the correct URL. If our URL:s contain spaces, we may also have to replace these with %20.

We'll also need to add these entries to the project's Info.plist, which is used to store build settings for the target:

Info.plist

After building the project, we can access the set values with:

Bundle.main.infoDictionary?["VOD_XML"] as! String).replacingOccurrences(of: "\\", with: ""
Enter fullscreen mode Exit fullscreen mode

Here we are removing the backslashes with replacingOccurences.

Copy files from file:// URL:s

Now we can access both bundled files and remote URL:s, but what about local files outside the project? For this, we're going to write a Swift script that detects file:// URL:s, reads and copies their content to bundled XML files at buildtime.

We add a new Swift file, in my case Scripts/CopyXml.swift, and create a new build phase in which we compile and run our script:

Build phases

Note that the order of the build phases matters, as the script needs to be run before the Copy Bundle Resources phase.

In case the URL entered by the user is invalid, we define an error:

enum fileReadError: Error {
    case invalidFileUrl(String)
}
Enter fullscreen mode Exit fullscreen mode

And add the throws keyword to the main function of the file.

We still want to read the values of VOD_XML and LIVE_XML in our Info.plist, but as we've yet to create our bundle, Bundle.main.infoDictionary is not available to us. Instead, we'll use ProcessInfo.processInfo.environment["VOD_XML"] to access it as an environmental variable. We'll also access the location of our project's root directory with the key SRCROOT.

We then check if the values start with the file:// protocol, in which case we attempt to load the contents of the file. If the loading fails, we throw the fileReadError that we defined. The error message will be printed in the build log rather than the console, as will any other error messages/prints that may occur when running buildtime scripts.

Build log with error

If the loading is successful, we construct a file:// URL pointing to either liveContentCopy.xml or vodContentCopy.xml, depending on if the input file:// URL is in LIVE_XML or VOD_XML. These files should be in the project root for the project to work, and copied to the app bundle in the Copy Bundle Resources phase. We write to these file:// URL with data.write(to: bundleFileUrl!), which either overwrites the existing file's data, or creates a new one if the file is missing.

It's worth noting that even if the files exist in the file system (i.e. were added through Finder, the terminal or in our code), they are not automatically referenced in the Xcode project. If we were to delete these files through the Xcode navigator and pass file URL:s in our Config.xcconfig, the data.write call in our code would create the new files in the file system, but Xcode would not see the files.

The solution is to add the files in the Project Navigator inside Xcode, as this will create a reference to the files, so that they can be copied to the app bundle in the Copy Bundle Resources phase.

Parsing XML/MRSS input

We should now have our input, either as an http(s):// URL, or the name of a bundled file (either added directly and referred to by name, or copied from en external file:// URL to either liveContentCopy or vodContentCopy).

We want our app to support MRSS feeds, which is a standard for distributing media over RSS. Here's an example of what liveContentCopy might look like, after reading from a file:// URL from LIVE_XML in our script:

<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
   <channel>
      <title>Eyevinn Live Example Streams</title>
      <link>https://www.eyevinntechnology.se/</link>
      <description>Contains live streams generated by Eyevinn Technology</description>
      <atom:link href="http://ENTER_FEED_URL_HERE_IF_UPLOADING.xml" rel="self" type="application/rss+xml" />
      <item>
         <title>Stockholm Office Weather</title>
         <description>A live RTSP stream of the weather otuside Eyevinn's Stockholm office</description>
         <guid isPermaLink="false">https://d2fz24s2fts31b.cloudfront.net/out/v1/6484d7c664924b77893f9b4f63080e5d/manifest.m3u8</guid>
         <media:category>All</media:category>
         <media:category>Weather</media:category>
         <media:content url="https://d2fz24s2fts31b.cloudfront.net/out/v1/6484d7c664924b77893f9b4f63080e5d/manifest.m3u8">
            <media:title type="plain">Stockholm Office Weather</media:title>
            <media:description type="html">A live RTSP stream of the weather otuside Eyevinn's Stockholm office</media:description>
            <media:thumbnail url="https://picsum.photos/id/1056/200/100.jpg" />
         </media:content>
      </item>
      <item>
         <title>HLS SSAI</title>
         <pubDate>Thu, 12 Oct 2017 00:00:00 -0700</pubDate>
         <link>http://sample-firetv-web-app.s3-website-us-west-2.amazonaws.com/spectators/</link>
         <description>A live stream of virtual racing action, with stitched in ads from Eyevinn's SSAI server</description>
         <guid isPermaLink="false">https://edfaeed9c7154a20828a30a26878ade0.mediatailor.eu-west-1.amazonaws.com/v1/master/1b8a07d9a44fe90e52d5698704c72270d177ae74/AdTest/master.m3u8</guid>
         <media:category>All</media:category>
         <media:category>Racing</media:category>
         <media:content url="https://edfaeed9c7154a20828a30a26878ade0.mediatailor.eu-west-1.amazonaws.com/v1/master/1b8a07d9a44fe90e52d5698704c72270d177ae74/AdTest/master.m3u8">
            <media:title type="plain">Spectators</media:title>
            <media:description type="html">A live stream of virtual racing action, with stitched in ads from Eyevinn's SSAI server</media:description>
            <media:thumbnail url="https://picsum.photos/id/1070/200/100.jpg" />
         </media:content>
      </item>
   </channel>
</rss>
Enter fullscreen mode Exit fullscreen mode

We also want to support a more plain style of RSS/XML, which could look like this:

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>livetestcontent.mrss.eyevinn.technology</id>
    <title>Feed for LIVE Test-Content</title>
    <updated>2022-05-05</updated>
    <entry>
        <title>HLS LIVE</title>
        <id>LIVE_1</id>
        <link>https://d2fz24s2fts31b.cloudfront.net/out/v1/6484d7c664924b77893f9b4f63080e5d/manifest.m3u8</link>
        <image>https://picsum.photos/id/1056/200/100.jpg</image>
    </entry>
    <entry>
        <title>HLS LIVE SSAI</title>
        <id>LIVE_2</id>
        <link>https://edfaeed9c7154a20828a30a26878ade0.mediatailor.eu-west-1.amazonaws.com/v1/master/1b8a07d9a44fe90e52d5698704c72270d177ae74/AdTest/master.m3u8</link>
        <image>https://picsum.photos/id/1070/200/100.jpg</image>
    </entry>
</feed>
Enter fullscreen mode Exit fullscreen mode

In our ContentView, we add some code to read the value of VOD_XML and LIVE_XML from our Info.plist, and we either use their values or, in the case of a file:// URL, the name of the copy destinations:

    private var vodXml: String = ((Bundle.main.infoDictionary?["VOD_XML"] as! String).replacingOccurrences(of: "\\", with: "").prefix(7) == "file://") ?
    "vodContentCopy" : (Bundle.main.infoDictionary?["VOD_XML"] as! String).replacingOccurrences(of: "\\", with: "")
Enter fullscreen mode Exit fullscreen mode

We also define a loadData() function, which we call in an .onAppear() hook attached to our Navigation View. loadData() will check the values of vodXml and liveXml, and either skip them, load them with a URLSession or from one of the bundled files:

    func loadData() {
        let xmlsIndexed = [vodXml, liveXml].enumerated()

        for (index, xml) in xmlsIndexed {
            let isLive = index == 1 ? true : false

            if xml == "" {
                continue
            } else if xml.prefix(7) == "http://" || xml.prefix(8) == "https://" {
                let url = URL(string: xml)
                let task = URLSession.shared.dataTask(with: url! as URL) {(data, response, error) in
                    if data != nil {
                        let feed = NSString(data: data!, encoding: String.Encoding.utf8.rawValue)! as String
                        parseXML(feed: feed, isLive: isLive)
                    }
                }
                task.resume()
            } else {
                let filenameInBundle = Bundle.main.path(forResource: xml, ofType: "xml")
                let data = NSData(contentsOfFile: filenameInBundle!)

                if data != nil {
                    let feed = NSString(data: data! as Data, encoding: String.Encoding.utf8.rawValue)! as String
                    parseXML(feed: feed, isLive: isLive)
                }
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

loadData() calls the function parseXML(), which should look like this:

    func parseXML(feed: String, isLive: Bool) {
        let xml = SWXMLHash.parse(feed)

        //For XML feed in correct format (see README)
        for elem in xml["feed"]["entry"].all
        {
            let videoItem = VideoItem()
            videoItem.title = elem["title"].element!.text
            videoItem.id = elem["id"].element!.text
            videoItem.videoUrl = elem["link"].element!.text
            videoItem.thumbnailUrl = elem["image"].element?.text != nil ? elem["image"].element!.text : ""
            isLive ? liveVideoItems.append(videoItem) : videoItems.append(videoItem)
        }

        //For MRSS feed in correct format (see README)
        for elem in xml["rss"]["channel"]["item"].all
        {
            let videoItem = VideoItem()
            videoItem.title = elem["title"].element!.text
            videoItem.id = elem["guid"].element!.text
            videoItem.videoUrl = (elem["media:content"].element?.attribute(by: "url")!.text)!
            videoItem.thumbnailUrl = (elem["media:content"]["media:thumbnail"].element?.attribute(by: "url")!.text) != nil ? (elem["media:content"]["media:thumbnail"].element?.attribute(by: "url")!.text)! : ""
            isLive ? liveVideoItems.append(videoItem) : videoItems.append(videoItem)
        }
    }
Enter fullscreen mode Exit fullscreen mode

In the function, we first call the parse() function of SWXMLHash on our input, and then iterate over the returned SWXMLHash.XMLIndexer value. Depending on the format of the input, we'll either iterate over every entry inside the feed, or every item inside the channel; create an VideoItem object for each, and append it to either our liveVideoItems or videoItems array.

Wrapping up

And with that, we should have a working tvOS SwiftUI app!

If you found something to be unclear, or you've gotten stuck, I would once again encourage you to clone the project.

I would also encourage you to check out the links below, which I found very helpful while working on this project!

Useful resources:

Parsing XML Using Swift

Swift Build Scripts

ObservedObject

VideoPlayer

Discussion (0)