The svelte 5 signal system is amazing. In the following we will imitate the cuechange
event on TextTrack
using svelte 5.
see TextTrack: cuechange event - Web APIs | MDN for more information on the event
Runes
Before we start, we need to know how reactivity works in svelte 5.
In this new version of the framework, declaring a dynamic value is no
longer made with the let
keyword but with the following statement:
let x = $state(0);
What is this you may ask. This is a rune. Runes are the new way to denote
reactivity in svelte. The $state(0)
declare a reactive state with the
initial value of 0
.
A very important feature of svelte 5 is the ability to use runes outside
of .svelte
files. Therefore we can move some logic out.
Once Upon A time
I was doing some coding as usual. And doing so, I wanted to write my own video player. In this player, I wanted a system to skip openings like on Netflix.
Video chapters can be represented as WebVTT And bind to the video as a TextTrack
.
Knowing that I can write sample.vtt
WEBVTT
1
02:15.000 --> 03:40.000
#SKIPINTRO
2
03:40.000 --> 23:55.000
#CONTENT
3
23:55.000 --> 25:20.000
#NEXTEP
I can load it with the video. That gives us the following code
<script lang="ts">
let track: HTMLTrackElement;
let skipIntro = $state(false);
let skipTo = $state(0);
let gotoNext = $state(false);
function cuechange() {
const active = track.activeCues[0] as VTTCue | undefined;
if (active) {
switch (active.text) {
case '#SKIPINTRO':
skipIntro = true;
skipTo = active.endTime / 1000;
break;
case '#CONTENT':
skipIntro = false;
gotoNext = false;
break;
case '#NEXTEP':
gotoNext = true;
break;
default:
skipIntro = false;
break;
}
} else {
gotoNext = false;
skipIntro = false;
}
}
...
</script>
{#if skipIntro}
<button id="action" onclick={skip}>Skip the into</button>
{/if}
{#if gotoNext}
<button id="action" onclick={next}>Watch the next episode</button>
{/if}
<video>
<source src={player.episode.videoUrl} type="video/mp4" />
<track
bind:this={track}
oncuechange={cuechange}
default
src={player.episode.chaptersUrl}
kind="chapters"
/>
</video>
This solution works well until it doesn’t.
I’m design the app as a single page application so when the video source
is changed and all its attributes are reset. Weirdly, the track
, even if we
update the source, is stuck on the previous video state. Making it totally
useless. If only TextTrack
had a reset
function.
ChapterManager
Now, let’s write our own system to deal with the problem.
We will consider a single Cue
as:
type Cue = {
startTime: number;
endTime: number;
text: string;
};
What do we need in our ChapterManager
?
- A way to reset the cues to another one
- The ability to add a
cuechange
listener - And update the active cues with the video time
Easy, right ? Let’s start by declaring the class that will contain the manager logic.
export default class ChapterManager {
private cues: Cue[] = $state([]);
private currentActiveCues: Cue[] = $state([]);
private ch: () => void = $state(() => {});
constructor(cues: Cue[]) {
this.cues = cues;
}
}
You can notice that cues
, currentActiveCues
, ch
are declared using
the $state
rune. This will be very useful later to signal the update of states
that depend on the manager values.
The reset
, oncuechange
setter and the activeCues
getter are as simple as:
export default class ChapterManager {
private cues: Cue[] = $state([]);
private currentActiveCues: Cue[] = $state([]);
private ch: () => void = $state(() => {});
constructor(cues: Cue[]) {
this.cues = cues;
}
// [!code ++:10]
reset(cues: Cue[]) {
this.cues = cues;
this.currentActiveCues = [];
}
get activeCues() {
return this.currentActiveCues;
}
set oncuechange(fn: () => void) {
this.ch = fn;
}
}
Now, we need to write the update function. If we want to really act like
the original TextTrack
, we will put the currentActiveCues
assignation behind
a setter. We will call it acs
:
export default class ChapterManager {
private cues: Cue[] = $state([]);
private currentActiveCues: Cue[] = $state([]);
private ch: () => void = $state(() => {});
constructor(cues: Cue[]) {
this.cues = cues;
}
reset(cues: Cue[]) {
this.cues = cues;
this.currentActiveCues = [];
}
get activeCues() {
return this.currentActiveCues;
}
set oncuechange(fn: () => void) {
this.ch = fn;
}
// [!code ++:6]
private set acs(val: Cue[]) {
if (!eq(val, this.currentActiveCues)) {
this.currentActiveCues = val;
this.ch();
}
}
}
const eq = (a: Cue[], b: Cue[]) => {
return a.length === b.length && a.every((element, index) => element === b[index]);
};
Finally, we can describe the update
update(time: number) {
this.acs = this.cues.filter((cue) => {
return cue.startTime / 1000 <= time && cue.endTime / 1000 >= time;
});
}
Going back to our svelte code, we will change a few things:
- Removing the track
- Create a binding to the video element. Allowing us to modify its
currentTime
- Introducing the
ChapterManager
. Notice that we don’t have to wrap it into the rune$state
to make it works as expected.
<script lang="ts">
let track: HTMLTrackElement; // [!code --]
let video: HTMLVideoElement; // [!code ++]
let mngr = new ChapterManager(player.episode?.chapters ?? []); // [!code ++]
mngr.oncuechange = cuechange; // [!code ++]
let skipIntro = $state(false);
let skipTo = $state(0);
let gotoNext = $state(false);
function cuechange() {
const active = track.activeCues[0] as VTTCue | undefined;
if (active) {
switch (active.text) {
case '#SKIPINTRO':
skipIntro = true;
skipTo = active.endTime / 1000;
break;
case '#CONTENT':
skipIntro = false;
gotoNext = false;
break;
case '#NEXTEP':
gotoNext = true;
break;
default:
skipIntro = false;
break;
}
} else {
gotoNext = false;
skipIntro = false;
}
}
// [!code ++:24]
function update() {
if (video) {
duration = video.duration;
currentTime = video.currentTime;
mngr.update(currentTime);
rPercentage = (currentTime * 100) / duration;
}
...
}
function skip() {
if (video) {
video.currentTime = skipTo;
}
}
async function next() {
if (player.onNext) {
await player.onNext();
mngr.reset(player.episode.chapters);
mngr.oncuechange = cuechange;
}
gotoNext = false;
}
...
</script>
{#if skipIntro}
<button id="action" onclick={skip}>Skip the into</button>
{/if}
{#if gotoNext}
<button id="action" onclick={next}>Watch the next episode</button>
{/if}
<video bind:this={video} ontimeupdate={update}>
<source src={player.episode.videoUrl} type="video/mp4" />
<track
bind:this={track}
oncuechange={cuechange}
default
src={player.episode.chaptersUrl}
kind="chapters"
/>
</video>
And that conclude this post…