javascript: create a <chives-video> component
- ID
675908a- date
2026-07-19 06:31:34+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
6321762- message
javascript: create a <chives-video> component- changed files
Changed files
CHANGELOG.md (6668 → 6850)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42600e1..c6afa6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,16 @@
# CHANGELOG
+## v49 - 2026-07-19
+
+Create a `<chives-video>` web component that renders an instance of `VideoEntity` created by `chives.media`.
+This standardises video rendering across my static sites.
+
## v48 - 2026-07-19
Start sharing front-end code between my projects.
This release introduces a `<chives-image>` web component that renders an instance of `ImageEntity` created by `chives.media`.
-This standardises common image rendering across my static sites.
+This standardises image rendering across my static sites.
This includes all the infrastructure for publishing JavaScript.
Subsequent releases will add more front-end code.
javascript/chives-image.js (6643 → 6561)
diff --git a/javascript/chives-image.js b/javascript/chives-image.js
index c1540d0..2fbfda5 100644
--- a/javascript/chives-image.js
+++ b/javascript/chives-image.js
@@ -1,5 +1,5 @@
// javascript/chives-image.js
-// chives version: 48
+// chives version: 49
// ChivesImage provides a <chives-image> component that renders
// an ImageEntity on a web page.
@@ -164,10 +164,6 @@ class ChivesImage extends HTMLElement {
wrapper.style.setProperty("--height", height);
wrapper.style.setProperty("--tint-colour", tintColour);
- if (typeof displayPath !== 'string') {
- console.log(displayPath);
- }
-
// Add a data-orientation property to the wrapper element.
const widthNum = parseFloat(width);
const heightNum = parseFloat(height);
javascript/chives-video.js (0 → 5147)
diff --git a/javascript/chives-video.js b/javascript/chives-video.js
new file mode 100644
index 0000000..551d83c
--- /dev/null
+++ b/javascript/chives-video.js
@@ -0,0 +1,195 @@
+// javascript/chives-video.js
+// chives version: 49
+
+// ChivesVideo provides a <chives-video> component that renders
+// a VideoEntity on a web page.
+//
+// Example usage:
+//
+// const elem = document.createElement("chives-video");
+//
+// elem.href = props.path;
+// elem.videoEntity = props;
+//
+class ChivesVideo extends HTMLElement {
+ static get observedAttributes() {
+ return [
+ //
+ // Fields from VideoEntity
+ "path",
+ "width",
+ "height",
+ "poster-path",
+ "subtitles",
+ "autoplay",
+ ];
+ }
+
+ // videoEntity allows you to pass a VideoEntity directly from JavaScript.
+ //
+ // For example:
+ //
+ // const elem = document.createElement("chives-video");
+ // elem.videoEntity = videoEntity;
+ //
+ set videoEntity(entity) {
+ this.setAttribute("path", entity.path);
+ this.setAttribute("width", entity.width);
+ this.setAttribute("height", entity.height);
+
+ this.setAttribute("poster-path", entity.poster.path);
+
+ if (Array.isArray(entity.subtitles)) {
+ this.setAttribute(
+ "subtitles",
+ entity.subtitles.map(s => s.label).join(";"),
+ );
+ entity.subtitles.forEach(s =>
+ this.#setSubtitleAttribute(s.label, s.path)
+ );
+ }
+
+ if (entity.autoplay) {
+ this.setAttribute("autoplay", "");
+ } else {
+ this.removeAttribute("autoplay");
+ }
+ }
+
+ // Rather than re-rendering the component on every attribute change
+ // (which will fire seven times when we set imageEntity), batch render
+ // changes together into the next event loop tick.
+ #renderTimeout = null;
+
+ connectedCallback() {
+ this.scheduleRender();
+ }
+
+ attributeChangedCallback() {
+ this.scheduleRender();
+ }
+
+ scheduleRender() {
+ if (this.#renderTimeout) {
+ cancelAnimationFrame(this.#renderTimeout);
+ }
+ this.#renderTimeout = requestAnimationFrame(() => {
+ this.render();
+ });
+ }
+
+ // Render the component.
+ render() {
+ // Gather properties from attributes
+
+ const path = this.getAttribute("path");
+ const width = this.getAttribute("width");
+ const height = this.getAttribute("height");
+
+ const posterPath = this.getAttribute("poster-path");
+
+ const subtitles = (this.getAttribute("subtitles") || "")
+ .split(";")
+ .filter(label => label !== "")
+ .map(label => {
+ const path = this.#getSubtitleAttribute(label);
+ return { label, path };
+ });
+
+ const autoplay = this.hasAttribute("autoplay");
+
+ // Create a wrapper div.
+ const wrapper = document.createElement("div");
+
+ // Add CSS classes and properties to the wrapper element.
+ wrapper.classList.add("chives_video");
+
+ wrapper.style.setProperty("--width", width);
+ wrapper.style.setProperty("--height", height);
+
+ // Add a data-orientation property to the wrapper element.
+ const widthNum = parseFloat(width);
+ const heightNum = parseFloat(height);
+
+ if (!isNaN(widthNum) && !isNaN(heightNum)) {
+ let orientation = "square";
+ if (widthNum > heightNum) {
+ orientation = "landscape";
+ } else if (widthNum < heightNum) {
+ orientation = "portrait";
+ }
+ wrapper.setAttribute("data-orientation", orientation);
+ }
+
+ // Build the <video> element.
+ const video = document.createElement("video");
+
+ if (autoplay) {
+ video.autoplay = true;
+ video.loop = true;
+ video.muted = true;
+ video.playsInline = true;
+ } else {
+ video.controls = true;
+ }
+
+ video.preload = "none";
+ video.src = path;
+ video.poster = posterPath;
+
+ subtitles.forEach(s => {
+ const track = document.createElement("track");
+ track.kind = "subtitles";
+ track.label = s.label;
+ track.src = s.path;
+ track.srclang = s.label.substring(0, 2).toLowerCase()
+ video.appendChild(track);
+ });
+
+ wrapper.appendChild(video);
+ this.replaceChildren(wrapper);
+ }
+
+ // We store subtitle paths in attributes like `subtitles-english-cc`.
+ //
+ // Because spaces and special characters break HTML attribute value parsing,
+ // tidy labels into lowercase, hyphen-separated strings.
+ #subtitleAttributeName(label) {
+ return "subtitles-" + label.replace(/[^a-zA-Z]+/g, "-").toLowerCase();
+ }
+
+ #setSubtitleAttribute(label, path) {
+ this.setAttribute(this.#subtitleAttributeName(label), path);
+ }
+
+ #getSubtitleAttribute(label) {
+ return this.getAttribute(this.#subtitleAttributeName(label));
+ }
+}
+
+// Register global styles for the <chives-video> element.
+if (typeof document !== "undefined" &&
+ !document.getElementById("chives-video-styles")) {
+ const style = document.createElement("style");
+ style.id = "chives-video-styles";
+ style.textContent = `
+ .chives_video {
+ display: block;
+ aspect-ratio: var(--width) / var(--height);
+ width: 100%;
+
+ video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ }
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+// Register the custom HTML tag <chives-video>
+if (!customElements.get("chives-video")) {
+ customElements.define("chives-video", ChivesVideo);
+}
python/chives/__init__.py (391 → 391)
diff --git a/python/chives/__init__.py b/python/chives/__init__.py
index cf48440..01fdb9c 100644
--- a/python/chives/__init__.py
+++ b/python/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
"""
-__version__ = "48"
+__version__ = "49"
tests/javascript/test_chives_video.py (0 → 5095)
diff --git a/tests/javascript/test_chives_video.py b/tests/javascript/test_chives_video.py
new file mode 100644
index 0000000..b91ef6b
--- /dev/null
+++ b/tests/javascript/test_chives_video.py
@@ -0,0 +1,179 @@
+"""
+Tests for the <chives-image> web component.
+"""
+
+import json
+
+from playwright.sync_api import expect, Page
+import pytest
+
+from chives.media import create_video_entity
+
+
+@pytest.fixture(autouse=True)
+def setup_chives_video_component(page: Page) -> None:
+ """
+ Intercept the page, open a blank state, and load the chives-video
+ component.
+ """
+ page.goto("about:blank")
+
+ with open("javascript/chives-video.js") as f:
+ component_js = f.read()
+
+ page.evaluate(component_js)
+
+
+def test_render_basic_video(page: Page) -> None:
+ """
+ The <chives-image> component can render a basic video entity.
+ """
+ e = create_video_entity(
+ video_path="tests/fixtures/media/wings_tracking_shot.mp4",
+ poster_path="tests/fixtures/media/wings_tracking_shot.jpg",
+ )
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-video");
+ elem.videoEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-video")
+
+ video_elem = chives_elem.locator("video")
+ expect(video_elem).to_be_visible()
+ assert (
+ video_elem.get_attribute("src")
+ == "tests/fixtures/media/wings_tracking_shot.mp4"
+ )
+ assert video_elem.get_attribute("autoplay") is None
+ assert video_elem.get_attribute("preload") == "none"
+
+
+def test_video_with_autoplay(page: Page) -> None:
+ """
+ If a video entity has autoplay, then <chives-video> sets the autoplay
+ attribute on the <video> element.
+ """
+ e = create_video_entity(
+ video_path="tests/fixtures/media/wings_tracking_shot.mp4",
+ poster_path="tests/fixtures/media/wings_tracking_shot.jpg",
+ autoplay=True,
+ )
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-video");
+ elem.videoEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-video")
+
+ video_elem = chives_elem.locator("video")
+ expect(video_elem).to_be_visible()
+ assert video_elem.get_attribute("autoplay") == ""
+
+
+@pytest.mark.parametrize(
+ "width, height, orientation",
+ [
+ (100, 100, "square"),
+ (200, 100, "landscape"),
+ (100, 200, "portrait"),
+ ],
+)
+def test_sets_orientation(
+ page: Page, width: int, height: int, orientation: str
+) -> None:
+ """
+ The <chives-video> element sets a data-orientation attribute.
+ """
+ e = create_video_entity(
+ video_path="tests/fixtures/media/wings_tracking_shot.mp4",
+ poster_path="tests/fixtures/media/wings_tracking_shot.jpg",
+ )
+ e["width"] = width
+ e["height"] = height
+
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-video");
+ elem.videoEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-video")
+
+ wrapper_elem = chives_elem.locator("div")
+ expect(wrapper_elem).to_be_visible()
+ assert (wrapper_elem).get_attribute("data-orientation") == orientation
+
+
+def test_sets_subtitles(page: Page) -> None:
+ """
+ If a video entity has subtitles, then <chives-video> adds a <track>
+ element for every subtitle.
+ """
+ e = create_video_entity(
+ video_path="tests/fixtures/media/wings_tracking_shot.mp4",
+ poster_path="tests/fixtures/media/wings_tracking_shot.jpg",
+ )
+ e["subtitles"] = [
+ {"label": "English", "path": "wings_tracking_shot.en.vtt"},
+ {"label": "French", "path": "wings_tracking_shot.FR.vtt"},
+ ]
+
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-video");
+ elem.videoEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-video")
+
+ video_elem = chives_elem.locator("video")
+ assert video_elem.locator("track").count() == 2
+
+ first_track = video_elem.locator("track").first
+ assert first_track.get_attribute("label") == "English"
+ assert first_track.get_attribute("src") == "wings_tracking_shot.en.vtt"
+
+
+@pytest.mark.parametrize("subtitle_label", ["English (CC)"])
+def test_subtitle_labels(page: Page, subtitle_label: str) -> None:
+ """
+ A video can have subtitles whose labels don't neatly map to
+ HTML attribute names.
+ """
+ e = create_video_entity(
+ video_path="tests/fixtures/media/wings_tracking_shot.mp4",
+ poster_path="tests/fixtures/media/wings_tracking_shot.jpg",
+ )
+ e["subtitles"] = [
+ {"label": subtitle_label, "path": "wings_tracking_shot.en.vtt"},
+ ]
+
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-video");
+ elem.videoEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-video")
+
+ video_elem = chives_elem.locator("video")
+ track_elem = video_elem.locator("track")
+ assert track_elem.get_attribute("label") == subtitle_label
tests/test_versions.py (391 → 491)
diff --git a/tests/test_versions.py b/tests/test_versions.py
index f8dfe0b..bb19771 100644
--- a/tests/test_versions.py
+++ b/tests/test_versions.py
@@ -13,4 +13,7 @@ def test_versions_in_sync() -> None:
The JavaScript components have the same version as the Python module.
"""
for filepath in glob.glob("javascript/*.js"):
- assert get_version(filepath) == __version__
+ print(filepath)
+ assert get_version(filepath) == __version__, (
+ f"want {__version__}, got {get_version(filepath)}"
+ )