Sebastian Ljungman
Posted on May 25, 2022
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.
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.
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 = ""
}
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 = ""
}
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
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()
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.
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")
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:
After building the project, we can access the set values with:
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 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:
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)
}
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.
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>
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>
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: "")
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)
}
}
}
}
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)
}
}
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:
Posted on May 25, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.