Skip to main content

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
6 files, 386 additions, 8 deletions

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)}"
+        )