diff --git a/README.md b/README.md
index 62d07d33..2516cc71 100644
--- a/README.md
+++ b/README.md
@@ -124,10 +124,11 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
| [Weather Display](./plugins/ledmatrix-weather/) | Current conditions, hourly & daily forecasts (Open-Meteo) |
|
| [Tide Display](./plugins/tide-display/) | Coastal tides with animated wave level, schedule & 24-hour chart (NOAA) |
|
-### Media (2)
+### Media (3)
| Plugin | Description | Preview |
|--------|-------------|---------|
+| [Jellyfin Now Playing](./plugins/jellyfin-now-playing/) | Poster, title and playback progress from your Jellyfin server |
|
| [Music Player](./plugins/ledmatrix-music/) | Now playing with album art (Spotify & YouTube Music) | |
| [Static Image Display](./plugins/static-image/) | Image display with scaling and transparency |
|
diff --git a/docs/assets/jellyfin-now-playing/content-types.png b/docs/assets/jellyfin-now-playing/content-types.png
new file mode 100644
index 00000000..772ad0d6
Binary files /dev/null and b/docs/assets/jellyfin-now-playing/content-types.png differ
diff --git a/docs/assets/jellyfin-now-playing/hero.png b/docs/assets/jellyfin-now-playing/hero.png
new file mode 100644
index 00000000..7d6df5fd
Binary files /dev/null and b/docs/assets/jellyfin-now-playing/hero.png differ
diff --git a/docs/assets/jellyfin-now-playing/panel-sizes.png b/docs/assets/jellyfin-now-playing/panel-sizes.png
new file mode 100644
index 00000000..5238e81a
Binary files /dev/null and b/docs/assets/jellyfin-now-playing/panel-sizes.png differ
diff --git a/docs/assets/jellyfin-now-playing/playback-states.png b/docs/assets/jellyfin-now-playing/playback-states.png
new file mode 100644
index 00000000..0ecab65d
Binary files /dev/null and b/docs/assets/jellyfin-now-playing/playback-states.png differ
diff --git a/docs/assets/jellyfin-now-playing/poster-album.png b/docs/assets/jellyfin-now-playing/poster-album.png
new file mode 100644
index 00000000..4bcb1f09
Binary files /dev/null and b/docs/assets/jellyfin-now-playing/poster-album.png differ
diff --git a/docs/assets/jellyfin-now-playing/poster-movie.png b/docs/assets/jellyfin-now-playing/poster-movie.png
new file mode 100644
index 00000000..f03fedd1
Binary files /dev/null and b/docs/assets/jellyfin-now-playing/poster-movie.png differ
diff --git a/docs/assets/jellyfin-now-playing/poster-series.png b/docs/assets/jellyfin-now-playing/poster-series.png
new file mode 100644
index 00000000..9cbeb002
Binary files /dev/null and b/docs/assets/jellyfin-now-playing/poster-series.png differ
diff --git a/docs/assets/jellyfin-now-playing/progress-bar-width.png b/docs/assets/jellyfin-now-playing/progress-bar-width.png
new file mode 100644
index 00000000..66ea18a9
Binary files /dev/null and b/docs/assets/jellyfin-now-playing/progress-bar-width.png differ
diff --git a/docs/assets/jellyfin-now-playing/sessions-idle.json b/docs/assets/jellyfin-now-playing/sessions-idle.json
new file mode 100644
index 00000000..50cc24c9
--- /dev/null
+++ b/docs/assets/jellyfin-now-playing/sessions-idle.json
@@ -0,0 +1,23 @@
+{
+ "matches": [
+ {
+ "url_contains": "/Sessions",
+ "body": []
+ },
+ {
+ "url_contains": "/Items/movie1/Images/Primary",
+ "body_file": "poster-movie.png",
+ "content_type": "image/png"
+ },
+ {
+ "url_contains": "/Items/series1/Images/Primary",
+ "body_file": "poster-series.png",
+ "content_type": "image/png"
+ },
+ {
+ "url_contains": "/Items/album1/Images/Primary",
+ "body_file": "poster-album.png",
+ "content_type": "image/png"
+ }
+ ]
+}
diff --git a/docs/assets/jellyfin-now-playing/sessions-paused.json b/docs/assets/jellyfin-now-playing/sessions-paused.json
new file mode 100644
index 00000000..38da4d43
--- /dev/null
+++ b/docs/assets/jellyfin-now-playing/sessions-paused.json
@@ -0,0 +1,37 @@
+{
+ "matches": [
+ {
+ "url_contains": "/Sessions",
+ "body": [
+ {
+ "UserName": "alex",
+ "NowPlayingItem": {
+ "Id": "movie1",
+ "Name": "Northern Lights",
+ "Type": "Movie",
+ "RunTimeTicks": 69600000000
+ },
+ "PlayState": {
+ "IsPaused": true,
+ "PositionTicks": 25200000000
+ }
+ }
+ ]
+ },
+ {
+ "url_contains": "/Items/movie1/Images/Primary",
+ "body_file": "poster-movie.png",
+ "content_type": "image/png"
+ },
+ {
+ "url_contains": "/Items/series1/Images/Primary",
+ "body_file": "poster-series.png",
+ "content_type": "image/png"
+ },
+ {
+ "url_contains": "/Items/album1/Images/Primary",
+ "body_file": "poster-album.png",
+ "content_type": "image/png"
+ }
+ ]
+}
diff --git a/docs/assets/jellyfin-now-playing/sessions.json b/docs/assets/jellyfin-now-playing/sessions.json
new file mode 100644
index 00000000..9eaa00a3
--- /dev/null
+++ b/docs/assets/jellyfin-now-playing/sessions.json
@@ -0,0 +1,69 @@
+{
+ "matches": [
+ {
+ "url_contains": "/Sessions",
+ "body": [
+ {
+ "UserName": "alex",
+ "NowPlayingItem": {
+ "Id": "movie1",
+ "Name": "Northern Lights",
+ "Type": "Movie",
+ "RunTimeTicks": 69600000000
+ },
+ "PlayState": {
+ "IsPaused": false,
+ "PositionTicks": 25200000000
+ }
+ },
+ {
+ "UserName": "sam",
+ "NowPlayingItem": {
+ "Id": "ep1",
+ "Name": "The Deep Field",
+ "Type": "Episode",
+ "RunTimeTicks": 28800000000,
+ "SeriesName": "Cosmos Unfolded",
+ "SeriesId": "series1"
+ },
+ "PlayState": {
+ "IsPaused": false,
+ "PositionTicks": 7200000000
+ }
+ },
+ {
+ "UserName": "jo",
+ "NowPlayingItem": {
+ "Id": "track1",
+ "Name": "Neon Harbour",
+ "Type": "Audio",
+ "RunTimeTicks": 2400000000,
+ "Artists": [
+ "The Glass Hours"
+ ],
+ "AlbumId": "album1"
+ },
+ "PlayState": {
+ "IsPaused": false,
+ "PositionTicks": 1200000000
+ }
+ }
+ ]
+ },
+ {
+ "url_contains": "/Items/movie1/Images/Primary",
+ "body_file": "poster-movie.png",
+ "content_type": "image/png"
+ },
+ {
+ "url_contains": "/Items/series1/Images/Primary",
+ "body_file": "poster-series.png",
+ "content_type": "image/png"
+ },
+ {
+ "url_contains": "/Items/album1/Images/Primary",
+ "body_file": "poster-album.png",
+ "content_type": "image/png"
+ }
+ ]
+}
diff --git a/docs/assets/jellyfin-now-playing/shots.json b/docs/assets/jellyfin-now-playing/shots.json
new file mode 100644
index 00000000..5e7599e9
--- /dev/null
+++ b/docs/assets/jellyfin-now-playing/shots.json
@@ -0,0 +1,73 @@
+{
+ "plugin": "jellyfin-now-playing",
+ "defaults": {
+ "width": 128,
+ "height": 32,
+ "scale": 6,
+ "freeze_time": "2026-09-02T21:00:00+00:00",
+ "http_replay": "sessions.json",
+ "config": {
+ "enabled": true,
+ "jellyfin_url": "http://192.168.1.50:8096",
+ "api_key": "docs-render-key",
+ "content_types": ["Movie"]
+ }
+ },
+ "shots": [
+ { "name": "hero" },
+ { "name": "type-movie", "standalone": false },
+ { "name": "type-episode", "standalone": false,
+ "config": { "content_types": ["Episode"] } },
+ { "name": "type-audio", "standalone": false,
+ "config": { "content_types": ["Audio"] } },
+ { "name": "playing", "standalone": false },
+ { "name": "paused", "standalone": false, "http_replay": "sessions-paused.json" },
+ { "name": "idle", "standalone": false, "http_replay": "sessions-idle.json" },
+ { "name": "bar-match", "standalone": false,
+ "width": 256, "config": { "progress_bar_match_text": true } },
+ { "name": "bar-full", "standalone": false,
+ "width": 256, "config": { "progress_bar_match_text": false } },
+ { "name": "size-64x32", "width": 64, "standalone": false },
+ { "name": "size-128x32", "width": 128, "standalone": false },
+ { "name": "size-128x64", "width": 128, "height": 64, "standalone": false },
+ { "name": "size-256x32", "width": 256, "standalone": false }
+ ],
+ "composites": [
+ {
+ "name": "content-types",
+ "columns": 1,
+ "cells": [
+ {"shot": "type-movie", "label": "Movie", "sublabel": "title, then the watching user"},
+ {"shot": "type-episode", "label": "Episode", "sublabel": "episode title, then the series"},
+ {"shot": "type-audio", "label": "Audio", "sublabel": "track title, then the artist"}
+ ]
+ },
+ {
+ "name": "playback-states",
+ "columns": 1,
+ "cells": [
+ {"shot": "playing", "label": "Playing"},
+ {"shot": "paused", "label": "Paused", "sublabel": "the bar turns amber"},
+ {"shot": "idle", "label": "Nothing playing"}
+ ]
+ },
+ {
+ "name": "progress-bar-width",
+ "columns": 1,
+ "cells": [
+ {"shot": "bar-match", "label": "progress_bar_match_text: true", "sublabel": "bar matches the text width"},
+ {"shot": "bar-full", "label": "progress_bar_match_text: false", "sublabel": "bar spans the text area"}
+ ]
+ },
+ {
+ "name": "panel-sizes",
+ "columns": 2,
+ "cells": [
+ {"shot": "size-64x32", "label": "64 x 32"},
+ {"shot": "size-128x32", "label": "128 x 32"},
+ {"shot": "size-128x64", "label": "128 x 64"},
+ {"shot": "size-256x32", "label": "256 x 32"}
+ ]
+ }
+ ]
+}
diff --git a/plugins.json b/plugins.json
index f8240000..ddcd868a 100644
--- a/plugins.json
+++ b/plugins.json
@@ -1116,10 +1116,10 @@
"plugin_path": "plugins/jellyfin-now-playing",
"stars": 0,
"downloads": 0,
- "last_updated": "2026-07-20",
+ "last_updated": "2026-09-02",
"verified": true,
"screenshot": "",
- "latest_version": "1.1.0"
+ "latest_version": "1.2.0"
},
{
"id": "incoming-packages",
diff --git a/plugins/jellyfin-now-playing/README.md b/plugins/jellyfin-now-playing/README.md
index 952a110e..4c0a3ec2 100644
--- a/plugins/jellyfin-now-playing/README.md
+++ b/plugins/jellyfin-now-playing/README.md
@@ -3,6 +3,13 @@
Shows what's currently playing on your Jellyfin media server: the poster on the
left, with the title, a subtitle, and a playback progress bar on the right.
+
+
+*Every image in this README is real plugin output, rendered at the true panel
+size against a recorded Jellyfin response and scaled up so the pixels stay
+pixels. The poster art in them is generated, not real cover art.*
+
- **Movies** show the movie poster with the title and the watching user's name.
- **TV episodes** show the *series* poster with the episode title and series name.
- **Music** shows the album art with the track title and artist.
@@ -10,8 +17,17 @@ left, with the title, a subtitle, and a playback progress bar on the right.
- The progress bar moves smoothly between polls and turns **amber with a ⏸
indicator** while playback is paused.
- When nothing is playing, a dim "Nothing Playing" screen is shown.
-- Works on all supported panel sizes (64×32, 128×32, 128×64, 256×32). Wide and
- tall panels also get a `position / duration` time readout.
+- Works on all supported panel sizes. Wide and tall panels also get a
+ `position / duration` time readout.
+
+
+
+The subtitle row is what changes between them: a movie shows who is watching,
+an episode shows its series, and a track shows its artist.
+
+
+
+
## Setup
@@ -46,7 +62,48 @@ Until both are set, the panel shows `Jellyfin: Set URL/API Key`.
| `scroll_enabled` | `true` | Marquee-scroll text that doesn't fit |
| `scroll_speed` | `5` | Frames per one-character scroll step; higher is slower (advanced) |
| `scroll_separator` | `" "` | Gap text between marquee repetitions (advanced) |
-| `customization` | — | Fonts, sizes, and colors for the title, subtitle, and progress bar |
+| `progress_bar_match_text` | `true` | Size the bar to the text rather than the whole text area (advanced) — see below |
+
+### Fonts and colors
+
+These live under `customization` in `config.json`, and under **Display
+Customization** in the web UI.
+
+| Key | Default | Description |
+|---|---|---|
+| `customization.title_text.font` | `5by7.regular.ttf` | Font for the media title (advanced) |
+| `customization.title_text.font_size` | `7` | Title height in pixels, 4–16 (advanced) |
+| `customization.title_text.text_color` | `[255, 255, 255]` | Title color |
+| `customization.subtitle_text.font` | `4x6-font.ttf` | Font for the subtitle and the time readout (advanced) |
+| `customization.subtitle_text.font_size` | `6` | Subtitle height in pixels, 4–16 (advanced) |
+| `customization.subtitle_text.text_color` | `[170, 170, 170]` | Subtitle color |
+| `customization.progress_bar.bar_color` | `[124, 77, 255]` | Filled portion of the bar. Ignored while paused, when the bar is amber |
+| `customization.progress_bar.background_color` | `[40, 40, 40]` | Unfilled portion of the bar |
+
+Five fonts are offered. Three are TrueType and work at every size in the 4–16
+range; two are `.bdf` bitmap faces, which exist at exactly one size each:
+
+| Font | Kind | Sizes |
+|---|---|---|
+| `5by7.regular.ttf` | TrueType | any |
+| `4x6-font.ttf` | TrueType | any |
+| `PressStart2P-Regular.ttf` | TrueType | any |
+| `5x7.bdf` | bitmap | 7 only |
+| `4x6.bdf` | bitmap | 6 only |
+
+Picking a bitmap font at a size it does not have used to fall back to a much
+smaller built-in font with only a log warning. It now loads at the font's own
+size instead, so the **Font Size** setting is simply ignored for those two.
+
+### Progress bar width
+
+`progress_bar_match_text` decides how far the bar runs. It matters most on a
+wide panel, where a short title otherwise leaves a bar stretched across the
+whole display:
+
+
+
+A title long enough to scroll fills the bar either way.
## Multiple sessions
diff --git a/plugins/jellyfin-now-playing/config_schema.json b/plugins/jellyfin-now-playing/config_schema.json
index 134e161d..918b4eba 100644
--- a/plugins/jellyfin-now-playing/config_schema.json
+++ b/plugins/jellyfin-now-playing/config_schema.json
@@ -106,8 +106,7 @@
"4x6-font.ttf",
"5by7.regular.ttf",
"5x7.bdf",
- "4x6.bdf",
- "cozette.bdf"
+ "4x6.bdf"
],
"default": "5by7.regular.ttf",
"x-advanced": true
@@ -152,8 +151,7 @@
"4x6-font.ttf",
"5by7.regular.ttf",
"5x7.bdf",
- "4x6.bdf",
- "cozette.bdf"
+ "4x6.bdf"
],
"default": "4x6-font.ttf",
"x-advanced": true
diff --git a/plugins/jellyfin-now-playing/manager.py b/plugins/jellyfin-now-playing/manager.py
index 1dda8eea..ad9b6e5e 100644
--- a/plugins/jellyfin-now-playing/manager.py
+++ b/plugins/jellyfin-now-playing/manager.py
@@ -159,7 +159,21 @@ def _load_font(self, font_name: str, font_size: int):
try:
font = ImageFont.truetype(candidate, font_size)
break
- except Exception as e:
+ except OSError as e:
+ # A .bdf is a bitmap face that exists at exactly one pixel
+ # size; FreeType refuses every other. Without this the
+ # offered bitmap fonts fall back to PIL's default at all
+ # but one of the sizes the schema allows.
+ native = self._bdf_pixel_size(candidate)
+ if native is not None and native != font_size:
+ try:
+ font = ImageFont.truetype(candidate, native)
+ self.logger.debug(
+ "Loaded bitmap font %s at its native size %d "
+ "(requested %d)", font_name, native, font_size)
+ break
+ except OSError:
+ pass
self.logger.warning("Could not load font %s: %s", candidate, e)
if font is None:
self.logger.warning("Font %s not found, using default", font_name)
@@ -168,6 +182,20 @@ def _load_font(self, font_name: str, font_size: int):
self._font_cache[cache_key] = font
return font
+ @staticmethod
+ def _bdf_pixel_size(path: str) -> Optional[int]:
+ """The pixel size a .bdf font declares, or None if it does not."""
+ try:
+ with open(path, "r", encoding="latin-1") as handle:
+ for line in handle:
+ if line.startswith("PIXEL_SIZE"):
+ return int(line.split()[1])
+ if line.startswith("CHARS"):
+ break # past the header; no point reading the glyphs
+ except (OSError, ValueError, IndexError):
+ return None
+ return None
+
def _dims(self) -> Tuple[int, int]:
"""Panel (width, height); prefers .width/.height, falls back to .matrix."""
width = getattr(self.display_manager, 'width', None)
diff --git a/plugins/jellyfin-now-playing/manifest.json b/plugins/jellyfin-now-playing/manifest.json
index e60acd29..d7cc0328 100644
--- a/plugins/jellyfin-now-playing/manifest.json
+++ b/plugins/jellyfin-now-playing/manifest.json
@@ -1,7 +1,7 @@
{
"id": "jellyfin-now-playing",
"name": "Jellyfin Now Playing",
- "version": "1.1.0",
+ "version": "1.2.0",
"author": "ChuckBuilds",
"description": "Shows what's playing on your Jellyfin server: poster art, title, and playback progress",
"category": "media",
@@ -26,10 +26,16 @@
"pillow"
],
"versions": [
+ {
+ "released": "2026-09-02",
+ "version": "1.2.0",
+ "notes": "Bitmap fonts and documentation. A .bdf face exists at exactly one pixel size, so picking 5x7.bdf or 4x6.bdf at any other size raised inside FreeType and fell back to PIL's default -- a much smaller font -- with only a log warning; twelve of the thirteen offered sizes were affected for each. They now load at their own size instead. cozette.bdf is dropped from both font menus: the core ships no such file, so it could never load at any size (#387). The README gains real rendered screenshots, documents progress_bar_match_text and all eight customization settings that were previously summarised in one line, and states which fonts work at which sizes.",
+ "ledmatrix_min": "2.0.0"
+ },
{
"released": "2026-07-29",
"version": "1.1.0",
- "notes": "Progress bar now matches the widest of the title and subtitle instead of spanning the whole text area, so a short episode name no longer leaves a bar stretched across a wide panel — and the blank remainder can be reclaimed in Vegas scroll mode. Disable with progress_bar_match_text.",
+ "notes": "Progress bar now matches the widest of the title and subtitle instead of spanning the whole text area, so a short episode name no longer leaves a bar stretched across a wide panel \u2014 and the blank remainder can be reclaimed in Vegas scroll mode. Disable with progress_bar_match_text.",
"ledmatrix_min": "2.0.0"
},
{
@@ -39,7 +45,7 @@
"notes": "Initial release: polls the Jellyfin /Sessions API and shows the active session's poster (series poster for TV episodes), scrolling title and subtitle, a paused indicator, and a progress bar that moves smoothly between polls. Includes username and content-type filters, a Nothing Playing screen, and on-panel setup/error messages on all four panel sizes."
}
],
- "last_updated": "2026-07-20",
+ "last_updated": "2026-09-02",
"stars": 0,
"downloads": 0,
"verified": true,
diff --git a/scripts/docs_render_support/_docs_http_replay.py b/scripts/docs_render_support/_docs_http_replay.py
index bdcefb26..8d4a0769 100644
--- a/scripts/docs_render_support/_docs_http_replay.py
+++ b/scripts/docs_render_support/_docs_http_replay.py
@@ -9,9 +9,14 @@
repeatable::
{"matches": [
- {"url_contains": "baseball/mlb/scoreboard", "body": { ...ESPN JSON... }}
+ {"url_contains": "baseball/mlb/scoreboard", "body": { ...ESPN JSON... }},
+ {"url_contains": "/Images/Primary", "body_file": "poster.png",
+ "content_type": "image/png"}
]}
+``body`` is served as JSON; ``body_file`` names a file beside the replay file
+and is served as raw bytes, for poster art, album covers and icons.
+
An entry may also carry ``params_contain``, for an API that puts several
endpoints behind one URL and tells them apart by query string::
@@ -33,21 +38,30 @@
class _ReplayResponse:
"""The slice of requests.Response that plugin fetch paths actually use."""
- def __init__(self, payload):
+ def __init__(self, payload, raw=None, content_type="application/json"):
self._payload = payload
+ self._raw = raw
self.status_code = 200
- self.headers = {"content-type": "application/json"}
+ self.headers = {"content-type": content_type}
self.encoding = "utf-8"
def json(self, **_kwargs):
+ if self._raw is not None:
+ raise ValueError("replayed body is binary, not JSON")
return self._payload
@property
def text(self):
+ if self._raw is not None:
+ return self._raw.decode("utf-8", "replace")
return json.dumps(self._payload)
@property
def content(self):
+ # Poster art, album covers and icons arrive as bytes, so a replayed
+ # response has to be able to carry a file rather than only JSON.
+ if self._raw is not None:
+ return self._raw
return self.text.encode("utf-8")
def raise_for_status(self):
@@ -74,6 +88,7 @@ def install():
matches = json.load(handle).get("matches", [])
if not matches:
return
+ base = os.path.dirname(os.path.abspath(path))
_MISS = object()
@@ -85,23 +100,29 @@ def _match(url, params):
wanted = entry.get("params_contain") or {}
if any(str(params.get(key)) != str(value) for key, value in wanted.items()):
continue
- return entry.get("body")
+ body_file = entry.get("body_file")
+ if body_file:
+ with open(os.path.join(base, body_file), "rb") as handle:
+ return _ReplayResponse(
+ None, raw=handle.read(),
+ content_type=entry.get("content_type", "application/octet-stream"))
+ return _ReplayResponse(entry.get("body"))
return _MISS
real_session_get = requests.Session.get
real_get = requests.get
def session_get(self, url, *args, **kwargs):
- body = _match(str(url), kwargs.get("params"))
- if body is _MISS:
+ response = _match(str(url), kwargs.get("params"))
+ if response is _MISS:
return real_session_get(self, url, *args, **kwargs)
- return _ReplayResponse(body)
+ return response
def plain_get(url, *args, **kwargs):
- body = _match(str(url), kwargs.get("params"))
- if body is _MISS:
+ response = _match(str(url), kwargs.get("params"))
+ if response is _MISS:
return real_get(url, *args, **kwargs)
- return _ReplayResponse(body)
+ return response
requests.Session.get = session_get
requests.get = plain_get