Back in January when I released hover-video-player v1.1, it felt like it was finally a “finished” project; the component was meant to be extremely simple, and it seemed like there were no more obvious gaps in basic functionality that still needed to be added.
The next week, I got a new issue asking for a feature which was not currently supported. Classic.
For those unfamiliar, hover-video-player is a web component which can be used to add videos to your page which
will play on hover, like a YouTube thumbnail preview. I use it on the homepage of this very site.
It’s based on the significantly more popular react-hover-video-player
library which I also maintain, but as I have fallen more and more out of love with React,
I confess that hover-video-player has become my favorite child.
The issue I received was a single sentence, a fairly innocent question: “Is there an easy way to keep one video playing at all times, even when none is hovering?”
This one put me in a bad mood. This project isn’t finished at all. I did not and still don’t have any plans to build specific behavior into the component which involves syncing with other components’ states, but my existing implementation didn’t expose APIs which would allow you control component state externally in order to implement that behavior yourself.
I didn’t feel like I had any good ideas on how to approach the problem, nor did I feel much motivation to figure it out, so I just let the issue sit unaddressed. I spent the next 4 months feeling weirdly guilty about ignoring it, and then suddenly I recently had a burst of motivation to just power through and get this thing done.
So, here’s what I did!
The changes
- Adds the ability to place a
<hover-video-player>component in “controlled” mode, meaning it will stop responding to hover events and allow you to have full programmatic control over when to start/stop video playback- This can be turned on by either setting a
controlledHTML attribute on the element or runninghoverVideoPlayer.controlled = truein JavaScript.
- This can be turned on by either setting a
- Adds a
hover()method to programmatically transition the player into a “hovering” state which will start playing the video - Adds a
blur()method to programmatically transition the player out of the “hovering” state to pause the video - The component will now respond to externally-set
data-playback-stateattribute values- This means you can make the video auto-play if you set an initial
data-playback-state="playing"attribute! - You can even start playback with
hoverVideoPlayer.dataset.playbackState = "playing"ifhoverVideoPlayer.hover()isn’t verbose enough.
- This means you can make the video auto-play if you set an initial
- Makes
hoverstartandhoverendevents cancelable- These events can now be intercepted and canceled with
event.preventDefault()if you want to prevent the player state from updating in response to a hover/blur event. - The new “controlled” mode is literally just syntactic sugar for calling
preventDefault()for bothhoverstartandhoverendevents every time they are emitted.
- These events can now be intercepted and canceled with
These new APIs unlock everything that is needed in order to build the experience that the original issue that prompted all of this was asking about. I made a proof-of-concept in codepen which works pretty well!
Unfortunately I got a little overly excited and what started as a clean v1.2.0
release quickly turned into 4 rapid follow-up versions with lots of bug fixes and API
refinements, finally bringing us to v1.2.4.
This is the last one, I swear.
Having to make that many follow-up releases for a project is not the best, I’ll openly admit that. However, there is something extremely powerful about publishing something before it’s done. Like clockwork, as soon as it goes up, I’ll think of another issue that I didn’t account for and then I’m back in the code mines fixing it. It’s not a good loop, but you can’t argue it doesn’t get results. In the future, I’m going to try using a beta channel for releases until I’m 100% confident that everything is set in stone; maybe that will produce a similar effect without also putting people using the library in harm’s way.
What I learned
This library has helped me discover a ton of patterns I love for writing web components. When I first wrote hover-video-player,
I stumbled into a new pattern which would later become popularized under the term “HTML Web Components”; components which
enhance child content instead of being concerned with rendering the content themselves.
It felt like an incredibly refreshing way of designing web components, and it even influenced
how I design React components (I still need to write a blog going into more depth on that).
My favorite takeaway from this work was that JavaScript’s built-in event interfaces are
really great to work with. I had to think very hard and go through a couple iterations figuring out how I could
expose a reasonable API for disabling a <hover-video-player> element’s default behavior when a hover/blur event is
fired, and achieving that by making the events cancelable with preventDefault() just feels so elegant and like it
really goes with the grain of how native HTML elements work. Super refreshing.
For any who are curious how cancelable events work, this is it:
const evt = new Event("myevent", {
cancelable: true,
});
// wasNotCanceled will be `false` if `evt.preventDefault()` was called in any handlers for this event.
// I kinda wish the values were flipped so it would return `true` if the event was canceled since this makes
// variable naming a little more awkward, but oh well.
const wasNotCanceled = eventTarget.dispatchEvent(evt);
I had no idea it was that easy!
However, I was also reminded that CustomEvents in TypeScript come with some extremely frustrating downsides;
there is no good way to register typings for the events which a custom element can emit, and even if you know
for certain that a given event will have a CustomEvent object instead of a plain Event object, TypeScript is
going to fight you every step of the way until you’ve either added a bunch of extremely obnoxious unnecessary runtime checks or have just given up and cast everything as any.
For example, TypeScript will complain about this code block:
// ts error: Argument of type '(evt: CustomEvent<"paused" | "loading" | "playing>) => void' is not assignable to parameter of type 'EventListenerOrEventListenerObject'.
hoverVideoPlayer.addEventListener("playbackstatechange", (evt: CustomEvent<"paused" | "loading" | "playing">) => {});
This is what you have to do to satisfy it while still having evt.detail typed as expected:
hoverVideoPlayer.addEventListener("playbackstatechange", (evt) => {
if (!(evt instanceof CustomEvent)) {
return;
}
// evt.detail is now typed as any. Not very typesafe. The only way to narrow the type from
// here is like this:
const newPlaybackState: unknown = evt.detail;
if(newPlaybackState !== "paused" && newPlaybackState !== "loading" && newPlaybackState !== "playing") {
return;
}
// newPlaybackState can now be treated as a union of the 3 valid playback states
});
As web components continue to gain steam, this feels like an issue that TypeScript needs to address sooner than later. I’d be very interested to see if there are any proposals out there on how to make this developer experience better.