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:
As the text does not only look like text but technically is text for the browser it can be marked and copied. It can then be pasted into a console on that system to reproduce what is shown in the "video".
People with visual impairments can access the content.
The "video" becomes searchable (especially if the narrated text or its keywords are added to the text frames invisibly).
The storage requirements would be tiny. Especially with synthesised speech. This would make an offline version of the whole collection (like man pages) feasible.
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.
Define an array with file names and time offsets between them.
Use audio.addEventListener to read the audio offset several times per second.
On each audio event calculate which file covers the current audio offset.
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.
<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:
const rawSlides = [
{ dt: 0.0, src: "screen-1.html" },
{ dt: 2.0, src: "screen-2.html" },
{ dt: 2.0, src: "last_screen-1.html" },
];
function buildTimeline(raw) {
let t = 0;
return raw.map(item => {
t += item.dt;
return { ...item, t };
});
}
const timeline = buildTimeline(rawSlides);
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()">
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,
};
}
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`;
}
}