Streaming Video on Demand with Hugo

TL;DR: cat video near the end of the pager.

Super hacky streaming VOD on Hugo.

No, I’m not a frontend dev. Yes, this is the first time I’ve touched frontend JS in nearly 8 years.

In theory the video stream will adapt to network conditions…

Why?

Last month I drove from Denver, CO to Menlo Park, CA and back, including some time off-roading and seeing sites. Normally when I travel I share a travel timelapse and some other video with friends and family on Facebook. Problem is, for the entirety of this trip most of my uploads from web or mobile app would fail with a webapp error. When I got home I decided to look for a way to self-host video and set up a Peertube instance, but it turns out that I don’t care about social features and didn’t want to leave complex software running just to host videos.

Hugo setup

Grab a release of Plyr and place plyr.polyfill.js & plyr.css in static/assets/video/ along with hls.js (ref)

Add the following to your theme’s layous/partials/header.html:

{{ $localvod_var := .Page.Scratch.Get "localvod" }}
{{ if eq $localvod_var "enabled" }}
<script type="text/javascript" src="/assets/video/hls.min.js"></script>
<script type="text/javascript" src="/assets/video/plyr.polyfilled.js"></script>
<script type="text/javascript" src="/assets/video/jhplayer.js"></script>
<link rel="stylesheet" href="/assets/video/plyr.css" />
{{ end }}

Create a shortcode by putting the following in layouts/shortcode/localvod.html:

{{ .Page.Scratch.Set "localvod" "enabled" }}
{{ .Page.Scratch.Add "localvod-instance" 1 }}

<video id="vod-container-{{ .Page.Scratch.Get "localvod-instance" }}" controls crossorigin playsinline >
  <source 
    type="application/x-mpegURL" 
    src="{{ .Get "hls" }}">
</video>
<script>
document.addEventListener("DOMContentLoaded", () => {
  const instID = "vod-container-{{ .Page.Scratch.Get "localvod-instance" }}";
  prepPlayerInstanceOnLoad(instID);
});
</script>

Put the following JS smashed together from various codepens & hackery into static/assets/video/jhplayer.js:

var hlsbag={};
function updateQuality(newQuality, instID) {
  hlsbag[instID].levels.forEach((level, levelIndex) => {
    if (level.height === newQuality) {
      console.log("Found quality match with " + newQuality);
      hlsbag[instID].currentLevel = levelIndex;
    }
  });
}

function prepPlayerInstanceOnLoad(instID) {
  const video = document.querySelector("video#" + instID);
  const source = video.getElementsByTagName("source")[0].src;

  // For more options see: https://github.com/sampotts/plyr/#options
  // captions.update is required for captions to work with hls.js
  const defaultOptions = {};

  if (Hls.isSupported()) {
    // For more Hls.js options, see https://github.com/dailymotion/hls.js
    const hls = new Hls();
    hls.loadSource(source);

    // From the m3u8 playlist, hls parses the manifest and returns
    // all available video qualities. This is important, in this approach,
    // we will have one source on the Plyr player.
    hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {

      // Transform available levels into an array of integers (height values).
      const availableQualities = hls.levels.map((l) => l.height)

      // Add new qualities to option
      defaultOptions.quality = {
        default: availableQualities[0],
        options: availableQualities,
        // this ensures Plyr to use Hls to update quality level
        forced: true,        
        onChange: (e) => updateQuality(e, instID),
      }

      // Initialize here
      const player = new Plyr(video, defaultOptions);
    });
    hls.attachMedia(video);
    hlsbag[instID] = hls;
  } else {
    // default options with no quality update in case Hls is not supported
    const player = new Plyr(video, defaultOptions);
  }
}

Video encoding

See transcode.sh (modded from this Gist).

Results

Test video 1: Playing with cat (HD)

Test video 2: Big dish rotate beep boop (4K)

TODO?

Outstanding issues

Just loading this page sends ~10-50MB of video segments to your browser for preload. This seems excessive. I tried to make it not preload but that seems to not be doing what I want. When transcoding, I should capture the first frame from the video and use that as a static preview image on the player and not fetch the first segment until mouseover event on the player or the play button is clicked, then start streaming as usual.

Automagical transcoding & HLS segment generation

I’d like to be able to upload a video file (not streaming) and have the player play that if it exists and a m3u8 playlist does not exist. I’d point the m3u8 playlist URL to a different server where I have a daemon running that regularly fetches RSS feeds and if a m3u8 URL exists pointing to it, fetches the video file, transcodes & generates HLS segments, and creates the m3u8 file at the URL. Perhaps an all-in-one bin in rust using ffmpeg bindings? That way I can upload once (saving bandwidth) and not deal with encoding on my laptop (saving time and power at events).

Want to keep reading? / go foward / go back