Text videos about console topics

During the development of the concept for the Linux video man pages the idea came up that videos which explain console commands do not require real videos, not even a sequence of images (screenshots). Switching HTML elements (<pre>) with Javascript is enough and offers huge advantages:

The technical approach would not be limited to Linux consoles, obviously. So there may be people who are interested in improving the simple technical implementation which is used for the video man pages.

The technical approach

  1. Define an array with file names and time offsets between them.

  2. Use audio.addEventListener to read the audio offset several times per second.

  3. On each audio event calculate which file covers the current audio offset.

  4. If the calculated file is different from the currently loaded file then replace the innerHTML of the container element with the file which is to be used now.

Example

<p>next: <span id="status"></span></p>

<div id="pre" class="pre">
<pre class="code"></pre>
</div>

<audio id="audio" controls src="text-video.ogg"></audio>

next:


Content definition array

const rawSlides = [
    { dt: 0.0, src: "screen-1.html" },
    { dt: 2.0, src: "screen-2.html" },
    { dt: 2.0, src: "last_screen-1.html" },
];

Initialising data structures

function buildTimeline(raw) {
    let t = 0;
    return raw.map(item => {
        t += item.dt;
        return { ...item, t };
    });
}

const timeline = buildTimeline(rawSlides);

Initialising the document

var audio;
var container;
var status;

function init() {
    audio = document.getElementById("audio");
    container = document.getElementById("pre");
    message = document.getElementById("status");

    container.dataset.index = String(-1);

    message.textContent = "0 (init)";

    audio.addEventListener("timeupdate", updateUI);
    audio.addEventListener("seeked", updateUI);
    audio.addEventListener("play", updateUI);
    audio.addEventListener("pause", updateUI);
    audio.addEventListener("loadedmetadata", updateUI);

    updateUI();
}

<body onload="init()">

Determine the content to be shown

function findSlideIndex(timeline, t) {
    let idx = 0;
    for (let i = 0; i < timeline.length; i++) {
        if (timeline[i].t <= t) idx = i;
        else break;
    }
    return idx;
}

function getNextSlideInfo(timeline, idx, currentTime) {
    if (idx + 1 >= timeline.length) return null;
    const next = timeline[idx + 1];
    return {
        absolute: next.t,
        inSeconds: Math.max(0, next.t - currentTime),
        src: next.src,
    };
}

Function called on each audio event

async function updateUI() {
    const t = audio.currentTime;
    const idx = findSlideIndex(timeline, t);

    if (container.dataset.index !== String(idx)) {
        innerhtml_filename = timeline[idx].src;
        await load(innerhtml_filename);
        container.innerHTML = html;
        container.dataset.index = String(idx);
    }

    const next = getNextSlideInfo(timeline, idx, t);

    // for the status line
    if (next) {
        message.textContent =
            `Current slide: ${idx + 1}/${timeline.length} | ` +
            `next change in ${next.inSeconds.toFixed(1)} s`;
    } else {
        message.textContent =
            `Current slide: ${idx + 1}/${timeline.length} | no further change`;
    }
}