Svelte 5 signal based event

en

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…