DEV Community

Bojan Todorović
Bojan Todorović

Posted on

ReacTV

Vizio, LG, Samsung, PS4, PS5, Xbox, VewD.
What do all these platforms have in common?
Yup, that's right, React!
All of these devices support web apps, and React is the web king.
At Redbox, a streaming service you might not have heard of, we run React on all of these platforms, from a single codebase.

Now you might think "oh, so it's just a regular web app, okay".
And you would be correct, up to a point.
But let's go beyond that point.

Challenges

There are a couple of challenges when developing TV web app that you don't generally encounter doing "normal" web apps.

  1. Ancient browsers
  2. Spatial navigation
  3. So many platforms
  4. Performance

Some of these are TV specific, but some can be applied to improve any web app.
So, don't worry if you're not starting a TV web app project tomorrow, might still find something for you below.

Ancient ones

Browsers on TVs can be old.
Old like Chrome v38(latest is v94), Opera v36(latest is v80), old non-Chromium Edge, Safari 6, etc.
And most of them are not vanilla browsers, but platforms built on top of these browsers.
Meaning there's always some custom code in there too, potentially making compatibility even more painful.
We come well prepared in the web world to deal with this, however. Most of the time browserslist will take care of it.

Still, two main issues can arise here:

  1. CSS - it can be a pain anywhere, but we all know old browsers are especially volatile.
  2. Transpilation - it is generally the practice to exclude node_modules from transpilation, as it decreases build time significantly. However, you may find for TVs that many modules over time drop support for browsers you simply have to continue supporting. You can include the whole node_modules in transpilation, but we've found including only a handful of modules with the issues works well. Ie:
include: [
    path.resolve(__dirname, 'src'),
    {
        include: path.resolve(__dirname, 'node_modules'),
        or: [/wonka/, /vtt-to-json/, /serialize-error/, /joi-browser/, /whatwg-fetch/],
    },
],
Enter fullscreen mode Exit fullscreen mode

Alternatively, there are tools like are-you-es5 that you can try out.

Spatial navigation

Besides your regular mouse and keyboard, TVs work with remotes.
There are modern "magic remotes" that function almost the same as the mouse.
But the classic remote requires navigating by arrow keys around your UX, or as commonly referred to, "spatial navigation".

There is nowadays this library for React react-spatial-navigation
However, one safe and secure way is to build your own React wrapper around the tried and tested Mozilla's open source spatial navigation.
And we have done just that.

So many platforms

Supporting all the browsers on the web from a single codebase is a pain, but much less pain then doing it with all of TVs.
For regular web apps, besides a browserslist, you might need an if to apply different styling or similar here and there, but that's about it.
TVs, on the other hand, are platforms built on top of browsers, and this is where the difficulty lies.
All of these platforms will have different ways to handle remote keys, TV specific events, to get device info, playback, etc.

There are a lot of ways to elegantly handle this platform specificity in a codebase and make it less painful.
Here's one:
Let's say you want to exit the application when exit button is pressed on the remote.
So you do this:

import { exitApplication } from '../../utils/device/device';

// .... call exitApplication in some event handler
Enter fullscreen mode Exit fullscreen mode

But, the trick is, every platform has it's own way of handling application exiting.
So, we make a device folder with the structure:

/device
    |- device.lg.js
    |- device.tizen.js
    |- device.xbox.js
    |- device.vizio.js
Enter fullscreen mode Exit fullscreen mode

And we make a little webpack magic.
Note that we have separate build script for every platform, so application is aware where it's being run by build script passing env.platform variable.

function platformizeExtensions(platform, extensions) {
    return [...extensions.map(extension => `.${platform}${extension}`), ...extensions];
Enter fullscreen mode Exit fullscreen mode

And in your webpack.config.js

resolve: {
        extensions: platformizeExtensions(env.platform, [
            '.mjs',
            '.js',
            '.jsx',
            '.scss',
        ]),
},
Enter fullscreen mode Exit fullscreen mode

For LG, this will make extensions look like this:

['.lg.mjs', '.lg.js', '.lg.jsx', '.lg.scss', '.mjs', '.js', '.jsx', '.scss'];
Enter fullscreen mode Exit fullscreen mode

This way, doing import { exitApplication } from '../../Utils/device/device'; will import from device file for the platform, ie on LG it will import from device.lg.js.
Problem solved.
Naturally, one caveat of this is that every device.*.js will have to export methods with the same name, otherwise you might encounter an error trying to import something that doesn't exist on some platforms.
Ie all of our device files have the same signature:

export const getDeviceId = () => {};
export const getOSVersion = () => {};
export const exitApplication = () => {};
export const isTTSEnabled = () => {};
export const isLowEndDevice = () => {};
Enter fullscreen mode Exit fullscreen mode

And we do the same with eg. keyCodes, since most platforms have keys on the remote dispatch onKeyDown event with their own custom set of keyCodes.
But, this little trick can have more use cases than just TV web app development.
One advantage of this approach over classical if or switch is that code in modules for other platforms is never imported, and therefore shaken off by webpack at bundling time, reducing bundle size.

Performance

You might have heard of "you need to watch for performance, mobile devices are low powered".
That is certainly true, until you encounter a new beast, a TV device.
Premium TV devices will probably be on par with mid range phones, which is great.
But budget TVs are more on par with a calculator.
I'm talking couple of hundred MHz processing power and 1GB or less RAM, shared with the operating system too.
Even a powerful platform like PlayStation, only allocates a small amount of resources to a web app, so in practice is also very low powered.

So, it's clear, you need to watch for performance, and not just like an afterthought.
That, however, involves multiple layers, not just React.
Let's go over some of the stuff you can do to preserve optimal experience on low end devices.

Measuring

A good starting point is always to continually run your app through well established performance measuring tools.
No single tool that I know of has everything regarding exposing performance flaws in your code, but a combination should do.
These tools are great for pointing out weak spots in terms of performance, and even suggesting improvements.

I'd mention:

  1. Lighthouse, Webpagetest, etc These ones do it from a simulated user perspective, what might be called "end to end", on a web app level. This is what you always want to have. But, they don't precisely point out flaws in your React code, so there's still a gap for another tool.
  2. React profiler Great for measuring and pointing out where you have performance bottlenecks in your React code. An absolute must.

Ideally, you'd want one of these tool in CI/CD pipeline.
But, we found that manual checks will always be required.

Assets

  1. Fonts - trying not to load huge file sizes for fonts is always sensible. For optimization, try preloading fonts with <link rel="preload" as="font"> and avoiding flash of invisible text while fonts are loading by using font-display API, ie font-display: swap;
  2. Images - ideally use webp format, and keep images as small as possible by loading in only what you need in terms of resolution. Ie, if user is on mobile, and image is displayed in ie 320x160, don't load huge image for desktop and resize it in-browser. This can be achieved by tools like Thumbor.
  3. Compression - gzip your data sent over network, that goes for API data and for JS/CSS files(which should be minimized too)

Preconnecting to relevant domains

Any app nowadays is bound to fetch a lot of stuff from other domains.
Things like data from your APIs, images from image server, etc.
Preconnecting to these domains or doing DNS prefetch might improve load time somewhat.
Learn the differences between these two and have them in mind as tools at your disposal
<link rel="preconnect" href="https://example.com">
<link rel="dns-prefetch" href="https://example.com">

Prefetch/preload, async/defer

Another set of tools that might come in handy is preload and prefetch.
Also, script async and defer.
Again, learn the differencies between these, so you're aware if and when to use them.
<link rel="prefetch" href="/bundle.js">
<link rel="preload" href="/something.chunk.js">
<script defer src="./script.js"></script>
<script async src="./script.js"></script>

Reflow vs Repaint

While this is somewhat advanced and you might not need it on a daily basis, learning the concept of browser repaint and reflow cycles might further expand your horizons when pondering performance.
And for general web performance overview, MDN is always a good starting point.

Code splitting

Code splitting with React and bundlers like webpack is extremely easy to setup, and you should almost always use it.
The most sensible way to start with is usually splitting your routes and maybe some parts of the application that are not accessed very frequently by users.

const Library = React.lazy(() =>
    import(
        /* webpackChunkName: "library" */ /* webpackPrefetch: true */ './Components/Library/Library'
    )
);
Enter fullscreen mode Exit fullscreen mode

Watch out for async/await

We all know async/await is great, right?
But one thing I noticed it has lead to, is the pitfall of sequential code where none is needed.
It's not once that I've seen in the wild code that awaits something, while there's code below hanging in there, even though it does not have to.
Ie

async componentDidMount() {
    const genres = await fetchGenres();
    this.setState({ genres });

    const isPlatformReady = await platformReady();

    if (isPlatformReady) {
        this.setState({ isPlatformReady: true });
    }
}
Enter fullscreen mode Exit fullscreen mode

In the case above, there's no reason for anything below line 3 to wait for genres to fetch.
Beware of sequential code, folks.

React components

Performance wise, React is great.
But, there are still stuff to watch out for.
Here's some:

  1. React.memo There are two "schools of thought" here. First is use it all the time, second one is use it sparingly. If you decide to use it all the time, you might end up slightly improving performance for some components, having little to no impact on others, and having negative impact on edge cases. If you decide to evaluate and use it sparingly only where it makes sense, you'll be safer, but it does consume more time(which is one of the main arguments for "use it all the time" I've seen). It sounds great in theory, but in practice it can easily prove "more trouble than it's worth". Eg. if a component has large number of props, might be the same or even faster to just let it re-render instead of making a costly check against all those props. Personally, I'm leaning towards checking in the profiler whether you're getting something out of it.
  2. Context is always somewhat costly to use. Make sure it's not overused. Prop drilldown isn't ideal, but it might save you some performance hits of having every component ever connected to global state management. One problem we encountered was with styled-components a couple of years ago, when we started the project. Not sure about now, but back then it used context for every single styled component. Needless to say, we noticed performance hits, and quickly switched to good old sass.
  3. useMemo and useCallback are generally worth it, with some exceptions. useMemo is great for your stuff that is derived from props/state and useCallback for your functions in components. Main thing to watch out for here is using these if their dependencies change too often. Ie, if you're memoizing function reference with useCallback, but it's dependency is ie inputValue which changes on every key press. In that case, useCallback just slows you down, as function reference will change anyway because of constantly changing dependency, you're just introducing memoization on top of recreating the function.

Virtualization

There are many great open source libraries for React which handle virtualization and lazy loading of components in lists.
Most notable being react-virtualized.
These are generally easy to setup and use, and solve almost all your problems of slow rendering in long lists of components.

However, because of spatial navigation, none of them satisfy our needs on TVs.
So, we built our own virtualization that works well for us, although we can't say we're too happy about having to allocate time for that.
Fortunately, if you're not running your web app on a TV, this is a problem you won't encounter.

Conclusion

And that about covers the main stuff.
Sure, there's also stuff like video playback, which is an epic narrative on it's own.
The accessibility, TV vendors usually have mandatory requirement for TTS accessibility in apps.
That's where we learned the hard way that WAI-ARIA standard is not much of a standard and that imperative TTS is much more maintainable.
And don't get me started on development experience TV vendors provide, or we might be here all day.
But, these are stories for another time.

Top comments (3)

Collapse
 
rubinelezi profile image
Rubin Elezi

Helpful article, can i ask what video player did you use?

Collapse
 
bojant987 profile image
Bojan Todorović

We have video player extracted in a separate npm package, as both TV web app and .com web app use it.
It abstracts all player logic, and uses 2 main open source players, based on a platform and content where it's used.

Shaka player is our main player, for standard DRM content.
Hls.js is a player we use for live streams, continuous TV streams.

This covers most of the cases, but there are edge cases where we use other players.
Like some of the older models of Tizen and WebOS TVs, where we load their native platform players for DRM content.

And on modern iOS Safari browsers, we load their native player, as it fully supports HLS content, so there's no need for a custom built player like shaka or hls.js.

Collapse
 
lowlifearcade profile image
Sonny Brown

Very intestine. Thanks for the article.