Creating an Apple TV app with SwiftUI
Tags: tvos, swiftui, tutorial, swift
Published: 2022-05-25 08:57:14

In 2019, Apple launched its new UI paradigm [SwiftUI](https://developer.apple.com/xcode/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](https://developer.apple.com/xcode/) and the CocoaPods package [SWXMLHash](https://cocoapods.org/pods/SWXMLHash), for parsing our XML input.    

![Video selection menu](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/di3828vnao460u58k33t.png)

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](https://docs.swift.org/swift-book/GuidedTour/GuidedTour.html). To learn SwiftUI, Apple has an excellent [tutorial project](https://developer.apple.com/tutorials/swiftui), 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](https://github.com/Eyevinn/tvos-swift-example-app) 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](https://cocoapods.org/pods/SWXMLHash). 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4qtmoipgqf3pbd3jp2e9.png)   
 

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:
```Swift
class VideoItem: Identifiable {
    var title = ""
    var id = ""
    var videoUrl = ""
    var thumbnailUrl = ""
}  
```
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`:
```Swift
class videoUrlObject: ObservableObject {
    @Published var currentVideoUrl = ""
    @Published var currentVideoTitle = ""
}
```
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](https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-observedobject-to-manage-state-from-external-objects):

```Swift
@ObservedObject var currentVideoUrlObject: videoUrlObject
```

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:

```Swift
    @State var videoItems = [VideoItem]()
    @State var liveVideoItems = [VideoItem]()
    @StateObject var currentVideoUrlObject = videoUrlObject()
```

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](https://wwdcbysundell.com/2021/using-swiftui-async-image/)) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/shbrsitghdeo5q43kc36.png)
    
## Building the VideoView
Besides creating the `ObservedObject`, we also `import AVKit` to create an instance of the [VideoPlayer View](https://www.hackingwithswift.com/quick-start/swiftui/how-to-play-movies-with-videoplayer). 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:
```Swift
Bundle.main.path(forResource: myVideos, ofType: "xml")
```

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
``` 

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tmmntaqwi0jrr70bdjhd.png)
 
After building the project, we can access the set values with:
```Swift
Bundle.main.infoDictionary?["VOD_XML"] as! String).replacingOccurrences(of: "\\", with: ""
```
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](https://www.raywenderlich.com/25816315-using-swift-scripts-with-xcode) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uew5oskvhbj956cwqtsa.png)
 
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:
```Swift
enum fileReadError: Error {
    case invalidFileUrl(String)
}
```
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6jnj07f7k3xeqqupsap6.png)
 
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] (https://www.rssboard.org/media-rss#media-content), 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
<?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>
```

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

```XML
<?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>
```

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:

```Swift
    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: "")
```

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: 

```Swift
    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)
                }
            }
        }
    }
```    

`loadData()` calls the function `parseXML()`, which should look like this:
```Swift
    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)
        }
    }
```
In the function, we first call the `parse()` function of [SWXMLHash](https://cocoapods.org/pods/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](https://github.com/Eyevinn/tvos-swift-example-app). 

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](https://medium.com/cracking-swift/parsing-remote-xml-using-swift-bfa9701fff84)

[Swift Build Scripts](https://www.raywenderlich.com/25816315-using-swift-scripts-with-xcode)

[ObservedObject](https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-observedobject-to-manage-state-from-external-objects)

[VideoPlayer](https://www.hackingwithswift.com/quick-start/swiftui/how-to-play-movies-with-videoplayer)