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) | ledmatrix-weather on an LED panel | | [Tide Display](./plugins/tide-display/) | Coastal tides with animated wave level, schedule & 24-hour chart (NOAA) | tide-display on an LED panel | -### Media (2) +### Media (3) | Plugin | Description | Preview | |--------|-------------|---------| +| [Jellyfin Now Playing](./plugins/jellyfin-now-playing/) | Poster, title and playback progress from your Jellyfin server | jellyfin-now-playing on an LED panel | | [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 | static-image on an LED panel | 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. +![A movie playing on a 128x32 panel: poster on the left, title and user name on +the right, with a partly filled progress bar](../../docs/assets/jellyfin-now-playing/hero.png) + +*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 three content types side by side](../../docs/assets/jellyfin-now-playing/content-types.png) + +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. + +![Playing, paused, and nothing playing](../../docs/assets/jellyfin-now-playing/playback-states.png) + +![The same session on four panel sizes](../../docs/assets/jellyfin-now-playing/panel-sizes.png) ## 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: + +![The bar matching the text versus spanning the text area](../../docs/assets/jellyfin-now-playing/progress-bar-width.png) + +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