Rewriting HMS With SceneGraph
Back in 2017 I mentioned that the API I have been using for my Roku Home Media Server project was going to be replaced by SceneGraph and that I would need to rewrite things. That day finally arrived when my newer Roku players updated themselves to v11.5.0 and HMS quit loading.
SceneGraph has lots of documentation, and lots of examples . Most of them are either too simplistic to be useful, or too complex to untangle for someone new to the framework, but experienced with the previous API. So it took me a bit to get things working without crashing at unexpected moments but I finally managed it. My solutions probably aren’t what a more experiences SceneGraph developer would choose, but they work well enough to put out a v4.0 release so I can get back to watching Magnum P.I.
This isn’t going to be a guide on how to write a SceneGraph application, I started out trying to do that and it just got too boring. Instead I’m going to describe some of the potholes I hit while doing this conversion. I started out by reading the docs, and following through one of their examples for building an application (while skipping the parts that loaded actual content). SceneGraph still uses brightscript, but it now also uses XML to describe the screen layout using Nodes . These are usually displayed on the screen, except for the Task and Timer nodes which are not. And they can be extended to combine them or add functionality to them.
It looks like you could create the nodes in your .brs
files instead of
dealing with XML, but that’s not how the examples work. One of the first
issues I had was figuring out how to combine the nodes to make a screen look
the way I imagined it should. This was difficult since the documentation
doesn’t really show you how to use the nodes together. I ended up making a
simple application and experimenting with them to see if I could make them
work.
The first step was to disable the old HMS main function, add a new one from
their examples, and created a MainScene.xml
and .brs
files under the
components
directory. That, combined with looking at existing Roku
applications, helped me come up with a plan for how I wanted it to look –
Category list on the left, posters on the right, and the category list should
collapse and expand when moving to and from the poster list.
The screen has a couple of Label nodes at the top of the screen, the one on the left is used for details about the selected video and the right one is used for a clock. The main part of the screen is made up of a PanelSet , and a Video node that will cover everything else when it is visible. There is also a Timer node which is used to update the clock.
The other major component of the HMS rewrite is the server url setup dialog. It uses a StandardKeyboardDialog to prompt the user for the url of the media server. This ended up being more difficult than you would expect. At first I tried to build my own dialog from various nodes, but then stumbled on the fact that there is a set of standard dialogs which ended up being exactly what I needed. They also have examples here .
One of the core concepts of SceneGraph is the
ObserveField
function. This is used to setup a callback that will be run when a field value
is changed. This ends up making the code harder to write and harder to follow,
but that’s how SceneGraph works so it’s an important part of rewriting things.
You cannot just create a dialog and get a result back from a function call. You
display the dialog, which gathers the user input, and when the dialog saves it
to a variable that is being ‘observed’ it runs the function setup by
ObserveField
which can then do something with the input.
So to get the result from the setup dialog I have to setup an observer on the dialog result. Then I want to verify the result is valid before storing it in the registry. But this means making some network requests to the server to make sure it exists, and to test whether it has a keyserver. This leads to another difficulty in using SceneGraph – you cannot run network requests from the render thread used to draw the screen elements. You cannot make the request from the setup dialog, or from the callback that is run when the dialog is done.
Network activity is only allowed from a Task node. I’ve created a couple of custom Task nodes for HMS to handle verifying the server url (xml , brs ), retrieving the list of categories (xml , brs ), and fetching the category metadata (xml , brs ). So you end up starting a task thread, making the request, which triggers an observer function, and finally taking some action based on the result. When HMS first runs it will loop, displaying the setup dialog until the user enters a valid url. The code looks like this:
' RunSetupServerDialog runs the dialog prompting the user for the server url
sub RunSetupServerDialog(url as string)
m.serverDialog = createObject("roSGNode", "SetupServerDialog")
m.serverDialog.ObserveField("serverurl", "OnSetupServerURL")
m.serverDialog.text = url
m.top.dialog = m.serverDialog
end sub
' OnSetupServerURL is called when the user has entered a url, it then validates it
' by calling RunValidateURLTask
sub OnSetupServerURL()
RunValidateURLTask(m.serverDialog.serverurl)
end sub
' RunValidateURLTask is called to validate the url that the user entered in the dialog
' it starts a task and calls OnValidateChanged when done.
sub RunValidateURLTask(url as string)
m.validateTask = CreateObject("roSGNode", "ValidateURLTask")
m.validateTask.serverurl = url
m.validateTask.ObserveField("valid", "OnValidateChanged")
m.validateTask.control = "run"
end sub
' OnValidateChanged checks the result of validating the URL and either runs the setup
' dialog again, or sets the serverurl which triggers loading the categories and the
' rest of the screen.
sub OnValidateChanged()
if not m.validateTask.valid then
' Still invalid, run it again
RunSetupServerDialog(m.validateTask.serverurl)
else
' Valid url, trigger the content load
m.top.serverurl = m.validateTask.serverurl
' And save it for next time
RegWrite("ServerURL", m.validateTask.serverurl)
m.keystoreTask.has_keystore = m.validateTask.keystore
end if
end sub
Nodes can be created using createObject
like I did above, or they can be defined in the
XML. When they are in the XML you need to get a reference to them by using
FindNode
in the BrightScript code. For example, I create the clock timer in
the XML but use m.clockTimer = m.top.FindNode("clockTimer")
to setup a
reference to it. For the clock the timer runs in parallel,
always resetting itself, and a function called UpdateClock
is called when it
fires.
' StartClock starts displaying the clock in the upper right of the screen
' It calls UpdateClock every 5 seconds
sub StartClock()
m.clock = m.top.FindNode("clock")
m.clockTimer = m.top.FindNode("clockTimer")
m.clockTimer.ObserveField("fire", "UpdateClock")
m.clockTimer.control = "start"
UpdateClock()
end sub
The main screen is made up of a PanelSet , which contains a ListPanel on the left for the list of categories and a GridPanel on the right to hold the PosterGrid showing all the videos available in the currently selected category. One of the tricky things about this setup is that you only need to setup one of each of these, including the PosterGrid. Initially I was creating a new PosterGrid when the new category was selected from the list. But this would cause the program to crash unexpectedly, and with no useful debug output. I changed to creating the GridPanel and PosterGrid once, then re-populating the PosterGrid when the category selection changes.
This part of the rewrite involved more observer callbacks of course. Setting
the serverurl
variable triggers loading the category list. This only needs
to run once, and it populates the ListPanel on the right. Then the selection
of a new list entry triggers loading the metadata for all the videos in the
category. This populates the PosterGrid with the posters. It doesn’t cache
anything, so depending on network speed and size of the directory it may take a
few seconds to show the new posters when you stop on a category.
Something else that is different from the old BrightScript only framework is that you now have to import the BrighScript code that you will be using. This is a bit awkward if you have divided things up into separate source files. For example, this is what I need to do to include all the source needed for fetching the category list:
<script type="text/brightscript" uri="pkg:/source/generalUtils.brs" />
<script type="text/brightscript" uri="pkg:/source/urlUtils.brs" />
<script type="text/brightscript" uri="pkg:/source/getDirectoryListing.brs" />
<script type="text/brightscript" uri="pkg:/source/getCategoryMetadata.brs" />
<script type="text/brightscript" uri="CategoryLoaderTask.brs" />
There is no way to do the import from BrightScript, it all has to be in the XML
for the node you are making the calls from. Which also means you need to keep
track of what the dependencies are for each .brs
file instead of letting
the file import its own dependencies. Maybe some of this could be avoided
by turning things into more custom nodes like I did for the server url
dialog. In this version the core logic is in
MainScene.brs
and the screen layout is in
MainScene.xml
.
Saving and restoring the last playback position using clortho was more of the same task, observer, callback procedure. Instead of immediately starting playback it tries to get the last position, defaults to 0 if there is no keystore or no entry, then passes the starting position to the Video player and starts playback.
' StartVideoPlayer is called with the index of the video to play
' It runs a keystore task to retrieve the last playback position for the
' selected video and then calls StartPlayback
sub StartVideoPlayer(index as integer)
m.video.content = m.metadata[index]
' Get the previous playback position, if any, and start playing
GetKeystoreValue(m.video.content.Title, "StartPlayback")
end sub
' StartPlayback is called by GetKeystoreValue which may have a starting
' position. If so, it is set, and playback is started.
sub StartPlayback()
ResetKeystoreTask()
' Was there a result?
if m.keystoreTask.value <> ""
m.video.seek = m.keystoreTask.value.ToInt()
end if
' Play the selected video
m.video.visible = true
m.video.SetFocus(true)
m.video.control = "play"
end sub
With all of those changes working for a few days I was confident enough to release HMS v4.0 which also includes the HMS.zip archive which can be uploaded directly to your player via the web interface if you don’t want to use the makefile method. I’m sure there are other, probably more correct, ways to accomplish this rewrite but my main goal was to get something working after having procrastinated long enough that most of my players weren’t working anymore.
If you have questions, patches, bugs, etc. feel free to file an issue on the HMS GitHub project .