I recently ended localizing Roku app. This article is about how I did it and why localization is even a problem.

Small proof that I am a developer

So let me first introduce myself. I am JS and BrightScript developer, I’ve been working with Roku, BrightScript and SceneGraph since about 2015.

My most successful job out there is the Locast Roku app, they hired me in august 2018 to make it, and we are still doing it.

Locast is a pretty interesting project, which is targeting USA-market, since it is all about USA laws allowing to re-broadcast local TV stations in the same area they are broadcasted. So if you live in NY, you would be able to watch NY tv with locast, it’s free, but you are allowed to Donate(which is always welcomed).

They did a lot of job, launched lots of cities(it’s 17 cities at the moment of writing this article). But then they decided to add Puerto Rico(which is kinda USA too). That’s when the interesting part started. So the situation is: the USA speaks English, but some parts speak Spanish. So we needed localization now.

Why is it a problem?

So, Roku has the ability to automatically translate all of your strings in the app. Those that go in the XML Layout of your components are translated silently, you don’t have to care about that. Strings in your .brs files are translated with the standard tr() method. All you need to do is provide a translations file in .ts or XLIFF. Case closed!

But here’s the problem. This functionality is relying on the current system locale. But the Locast wanted users to have the ability to switch. So you might have your system set in English, but the app in Spanish, and vice versa. You can’t do this with standard tr().

So this is now the problem. How do you do that?

Problems to solve

  1. You need to keep translations somewhere not in .ts or XLIFF. Not really a problem, that seemed easier for me to have some object instead of XML
  2. You need to implement your own tr(), which would accept a string and a locale as parameters and return a translated string if it exists, and original string otherwise.
  3. I originally planned to end up with this, but that would lead to re-writing all of my components, removing all the strings from XLM-layout and having them hardcoded and set dynamically in some function inside .brs script. That would also require having set an ID for each small label in the app. And I suck at naming. So third problem is somehow I needed to go through the elements-tree and translate those strings on the go.

You also need to detect the current locale to start with. Keep it in Registry and write it again if the user changes it in Settings. But we’re not gonna discuss that.

Where to keep translations?

Not really a problem. I ended up having my object stored in a variable. The only place I need that object is tr(), but to keep the tr() small, I extracted the obj to another function which just returns it. Here’s the shape of my object:

function _getTranslations() as Object
return {
"Hello: {"es_ES": "Hola"}
}
end function

Implementing tr()

tr() is reserved. So we gonna name it a _tr()(I told you I suck at naming). Let’s have a look:

function _tr(txt as String, locale as Dynamic) as String
if locale = invalid
return txt
end if
obj = _getTranslations()
translations = obj[txt]
if (translations <> invalid and translations[locale] <> invalid)
return translations[locale]
end if
return txt
end function

So if something goes wrong, we return original txt, if everything goes well we return translated string.

But the third part is quite interesting

So we wanted to change it on the go. We can translate our strings in .brs by using _tr(), but how do we translate already created RoSGNodes? I am using PanelSet in my app, so I have different screens as different components. Good news is that the whole app is tree-structured. My first attempt was to do this:

sub init()
setTranslations(m.top, m.global.locale)
end sub
sub setTranslations(node, locale)
if node = invalid then return
for i = 0 to node.getChildCount() - 1
item = node.getChild(i)
if item.text <> invalid
item.text = _tr(item.text, locale)
end if
end for
end sub

So we recursively go through the component and if it has text or title field we translate it. ¡Excelente!

But let’s say user changes settings back to English. We trigger our setTranslations and you know what happens next, right? Nothing. Because there’s no such key as ‘hola’ in the translations object. _Tr doesn’t know it is translating back. We designed it to translate from English to anything else.

To solve that we need to keep the original somehow. In HTML app I would probably just keep it in data-* attribute and use that for translations. You can’t simply add data-* in XML-layout, though. So here’s what I came up with:

sub setTranslations(node, locale)
if node = invalid then return
for i = 0 to node.getChildCount() - 1
item = node.getChild(i)
translateField(item, "text", locale)
translateField(item, "title", locale)
translateField(item, "hintText", locale)
translateField(item, "description", locale)
setTranslations(item, locale)
end for
end sub
sub translateField(item, field, locale)
if item[field] <> invalid
value = item["data-" + field]
if value = invalid
value = item[field]
if value <> ""
item.addField("data-" + field, "string", false)
item["data-"+field] = value
end if
end if
newValue = _tr(value, locale)
item[field] = newValue
end if
end sub

We extended list of fields we update. And move translation into other function. If there’s no such field, it does nothing. If there’s a data-* field it uses data-* field and if there’s no data-* field it uses field and keeps original in the data. Also, it does nothing when the field is an empty string.

At this point, I did a timer that switches m.global.locale each 5 seconds, subscribed for this value and called setTranslations on my whole app. So I could see the cases where it didn’t work.

Could that be possible it didn’t work at some of the components?

Oh God yes! So let’s see some cases:

  1. Component didn’t have the value set when it did update and received it later with _tr(). Or it had value but it changed with _tr() and data-* stayed the same, so when language changes it changes to wrong value.
  2. Your custom component has a title, but it’s nested elements also have title set with alias or somehow.

So we probably don’t want to some parts to update like this in such brutal way, we would rather update them manually. This means we need to skip some nodes. Since we can’t add data-* on the markup side, I decided to do some hacky stuff(and would be glad to know your cleaner suggestions in the comment section), but here’s my solution:

sub setTranslations(node, locale)
if node = invalid then return
for i = 0 to node.getChildCount() - 1
item = node.getChild(i)
if Left(item.id, 1) <> "_"
translateField(item, "text", locale)
translateField(item, "title", locale)
translateField(item, "hintText", locale)
translateField(item, "description", locale)
setTranslations(item, locale)
end if
end for
end sub

So if the item.id starts with the “_” we skip it. And with that you can avoid brutal translations(I like to call it that way). Subscribe to the m.global.locale locally in your nested components and update in a more sophisticated way.

JS, React, BrightScript dev at Jaybirdgroup