From 8b9bcedb92d4fd387e2bc5ff4d6b94ced68d0bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 17:24:36 -0400 Subject: [PATCH] fix(jellyfin-now-playing): size the progress bar to the text, not the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _render_now_playing gave the text area every remaining column and then drew the progress bar across all of it: text_w = width - text_x - 1 bar_x2 = text_x + text_w - 1 So the rendered frame was full-width whatever the title length. On a 512px panel a short episode name left a bar stretching across the display, and because a bar is drawn pixels rather than blank space, Vegas mode could not trim it back — the plugin was contributing a full screen width per pass. The bar is now sized to the widest of the title and subtitle, with a 24px floor so a very short title still reads as a progress indicator. That also makes the remainder genuinely blank, so a ticker can reclaim it. A title long enough to be marqueed measures wider than the area and still fills the bar, which is correct. Set progress_bar_match_text false for the previous full-width bar. The safety harness cannot cover this: without a reachable Jellyfin server there is no session, so the now-playing frame is never rendered and the harness only ever sees the "Nothing Playing" screen. Hence the focused unit tests in test_content_width.py, which exercise the sizing rule directly. Harness passes at all sizes; module collisions clean. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins.json | 4 +- plugins/jellyfin-now-playing/CHANGELOG.md | 13 ++ .../jellyfin-now-playing/config_schema.json | 6 + plugins/jellyfin-now-playing/manager.py | 51 +++++++- plugins/jellyfin-now-playing/manifest.json | 8 +- .../test_content_width.py | 116 ++++++++++++++++++ 6 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 plugins/jellyfin-now-playing/CHANGELOG.md create mode 100644 plugins/jellyfin-now-playing/test_content_width.py diff --git a/plugins.json b/plugins.json index 41ef668a..9e7eaa11 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-28", + "last_updated": "2026-07-29", "plugins": [ { "id": "cricket-scoreboard", @@ -1094,7 +1094,7 @@ "last_updated": "2026-07-20", "verified": true, "screenshot": "", - "latest_version": "1.0.0" + "latest_version": "1.1.0" }, { "id": "incoming-packages", diff --git a/plugins/jellyfin-now-playing/CHANGELOG.md b/plugins/jellyfin-now-playing/CHANGELOG.md new file mode 100644 index 00000000..1034f3a1 --- /dev/null +++ b/plugins/jellyfin-now-playing/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## [1.1.0] - 2026-07-29 + +### Fixed +- **Took the full panel width regardless of title length**: the progress bar was + drawn across the whole text area, so the rendered frame was always full-width — + on a 512px panel a short episode name left a bar stretching across the display. + Because a bar is drawn pixels, a ticker cannot trim it back. It is now sized to + the widest of the title and subtitle, which also lets the blank remainder be + reclaimed in Vegas scroll mode. A title long enough to scroll still fills the + bar. Set `progress_bar_match_text` false for the original behaviour. + diff --git a/plugins/jellyfin-now-playing/config_schema.json b/plugins/jellyfin-now-playing/config_schema.json index ca92fcb4..134e161d 100644 --- a/plugins/jellyfin-now-playing/config_schema.json +++ b/plugins/jellyfin-now-playing/config_schema.json @@ -44,6 +44,12 @@ "description": "Which media types to show", "x-widget": "checkbox-group" }, + "progress_bar_match_text": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Size the progress bar to the widest of the title and subtitle instead of stretching it across the whole text area. On a wide panel a short title otherwise leaves a bar spanning the display, and because a bar is drawn pixels it cannot be trimmed back in Vegas scroll mode. A title long enough to scroll still fills the bar. Turn off for the original full-width bar." + }, "show_progress": { "type": "boolean", "default": true, diff --git a/plugins/jellyfin-now-playing/manager.py b/plugins/jellyfin-now-playing/manager.py index 0cdf9f7c..1dda8eea 100644 --- a/plugins/jellyfin-now-playing/manager.py +++ b/plugins/jellyfin-now-playing/manager.py @@ -48,8 +48,14 @@ class JellyfinNowPlayingPlugin(BasePlugin): show_progress (bool): Draw the playback progress bar show_paused (bool): Treat paused sessions as playing update_interval (int): Session poll interval in seconds (default: 10) + progress_bar_match_text (bool): Size the progress bar to the widest + text line rather than the whole text area (default: True) """ + # Floor for the content-matched progress bar, so a very short title still + # leaves something recognisable as a progress indicator. + MIN_PROGRESS_BAR_WIDTH = 24 + def __init__(self, plugin_id: str, config: Dict[str, Any], display_manager, cache_manager, plugin_manager): """Initialize the Jellyfin Now Playing plugin.""" @@ -523,14 +529,20 @@ def _render_now_playing(self) -> Image.Image: draw.text((text_x, subtitle_y + sub_h + 3), time_str, font=self.subtitle_font, fill=self.subtitle_color) - # Progress bar along the bottom of the text area + # Progress bar along the bottom of the text area, no wider than the text + # it sits under. Spanning the whole text area made the frame full-width + # whatever the title length: on a 512px panel a short episode name left a + # bar stretching across the display, and because a bar is drawn pixels + # there is nothing for a ticker to trim back. Sizing it to the content + # keeps the block compact and lets the blank remainder be reclaimed. if self.show_progress and duration_s > 0: + bar_w = self._content_width(text_w) bar_y = height - bar_h - 2 - bar_x2 = text_x + text_w - 1 + bar_x2 = text_x + bar_w - 1 draw.rectangle([text_x, bar_y, bar_x2, bar_y + bar_h - 1], fill=self.bar_background) progress = min(1.0, max(0.0, position_s / duration_s)) - fill_w = int(round((text_w - 1) * progress)) + fill_w = int(round((bar_w - 1) * progress)) if fill_w > 0: color = PAUSED_BAR_COLOR if info['is_paused'] else self.bar_color draw.rectangle([text_x, bar_y, text_x + fill_w, bar_y + bar_h - 1], @@ -538,6 +550,39 @@ def _render_now_playing(self) -> Image.Image: return image + def _content_width(self, available: int) -> int: + """ + Width of the widest text line, capped at ``available``. + + Used to size the progress bar to its content instead of the whole text + area. A line long enough to be marqueed measures wider than the area and + so pins the bar to full width, which is right — that line really does + fill it. A floor keeps a very short title from leaving a stub too small + to read as a progress indicator. + + Set ``progress_bar_match_text`` false for the original full-width bar. + """ + if not self.config.get('progress_bar_match_text', True): + return available + + info = self.now_playing or {} + widest = 0 + for text, font in ( + (info.get('title'), self.title_font), + (info.get('subtitle'), self.subtitle_font), + ): + if not text: + continue + try: + widest = max(widest, self._text_width(text, font)) + except Exception: + # Measurement is best-effort; never lose the bar over it. + return available + + if widest <= 0: + return available + return max(min(self.MIN_PROGRESS_BAR_WIDTH, available), min(widest, available)) + def _current_position(self) -> Tuple[int, int]: """Playback position extrapolated between polls, clamped to duration.""" info = self.now_playing diff --git a/plugins/jellyfin-now-playing/manifest.json b/plugins/jellyfin-now-playing/manifest.json index 6fc0c20a..e60acd29 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.0.0", + "version": "1.1.0", "author": "ChuckBuilds", "description": "Shows what's playing on your Jellyfin server: poster art, title, and playback progress", "category": "media", @@ -26,6 +26,12 @@ "pillow" ], "versions": [ + { + "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.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-20", "version": "1.0.0", diff --git a/plugins/jellyfin-now-playing/test_content_width.py b/plugins/jellyfin-now-playing/test_content_width.py new file mode 100644 index 00000000..3a872928 --- /dev/null +++ b/plugins/jellyfin-now-playing/test_content_width.py @@ -0,0 +1,116 @@ +""" +Tests for JellyfinNowPlayingPlugin._content_width. + +The progress bar used to span the whole text area, which made the rendered frame +full-width whatever the title length — on a 512px panel a short episode name left +a bar stretching across the display. A bar is drawn pixels, so a ticker cannot +trim it back; it has to be narrower in the first place. + +The safety harness cannot cover this: without a reachable Jellyfin server there +is no session, so the now-playing frame is never rendered. +""" + +import sys +import types + +import pytest + +# The plugin imports BasePlugin from the core, which is not on the path here. +if 'src' not in sys.modules: + src = types.ModuleType('src') + plugin_system = types.ModuleType('src.plugin_system') + base_plugin = types.ModuleType('src.plugin_system.base_plugin') + + class _BasePlugin: + def __init__(self, *args, **kwargs): + pass + + base_plugin.BasePlugin = _BasePlugin + plugin_system.base_plugin = base_plugin + src.plugin_system = plugin_system + sys.modules['src'] = src + sys.modules['src.plugin_system'] = plugin_system + sys.modules['src.plugin_system.base_plugin'] = base_plugin + +TEXT_AREA = 300 +TITLE_FONT = object() +SUBTITLE_FONT = object() + + +def make_plugin(title='', subtitle='', config=None, px_per_char=6, fail=False): + """A plugin shell with a no-op __init__; only the sizing logic is exercised.""" + from manager import JellyfinNowPlayingPlugin + + class _Shell(JellyfinNowPlayingPlugin): + def __init__(self): + pass + + def _text_width(self, text, font): + if fail: + raise RuntimeError("font unavailable") + return len(text) * px_per_char + + plugin = _Shell() + plugin.config = config if config is not None else {} + plugin.title_font = TITLE_FONT + plugin.subtitle_font = SUBTITLE_FONT + plugin.now_playing = {'title': title, 'subtitle': subtitle} + return plugin + + +class TestContentWidth: + def test_matches_the_widest_line(self): + # subtitle is longer: 12 chars -> 72px + plugin = make_plugin(title='Ep 1', subtitle='Season Three') + assert plugin._content_width(TEXT_AREA) == 72 + + def test_short_title_no_longer_spans_the_area(self): + plugin = make_plugin(title='Up', subtitle='') + width = plugin._content_width(TEXT_AREA) + assert width < TEXT_AREA + assert width == 24 # the floor + + def test_never_exceeds_the_available_area(self): + plugin = make_plugin(title='x' * 200, subtitle='y' * 200) + assert plugin._content_width(TEXT_AREA) == TEXT_AREA + + def test_marqueed_title_pins_the_bar_to_full_width(self): + # A title long enough to scroll genuinely fills the area. + plugin = make_plugin(title='z' * 60, subtitle='') + assert plugin._content_width(TEXT_AREA) == TEXT_AREA + + def test_floor_cannot_exceed_a_narrow_area(self): + plugin = make_plugin(title='A', subtitle='') + assert plugin._content_width(10) == 10 + + def test_empty_strings_fall_back_to_the_area(self): + plugin = make_plugin(title='', subtitle='') + assert plugin._content_width(TEXT_AREA) == TEXT_AREA + + def test_missing_session_does_not_raise(self): + plugin = make_plugin() + plugin.now_playing = None + assert plugin._content_width(TEXT_AREA) == TEXT_AREA + + def test_disabled_by_config_restores_full_width(self): + plugin = make_plugin(title='Up', config={'progress_bar_match_text': False}) + assert plugin._content_width(TEXT_AREA) == TEXT_AREA + + def test_enabled_by_default(self): + plugin = make_plugin(title='Up', config={}) + assert plugin._content_width(TEXT_AREA) < TEXT_AREA + + def test_measurement_failure_keeps_the_bar(self): + # Losing the bar entirely would be worse than an over-wide one. + plugin = make_plugin(title='Something', fail=True) + assert plugin._content_width(TEXT_AREA) == TEXT_AREA + + def test_subtitle_alone_is_enough(self): + plugin = make_plugin(title='', subtitle='A Longer Subtitle') + assert plugin._content_width(TEXT_AREA) == len('A Longer Subtitle') * 6 + + @pytest.mark.parametrize('area', [1, 24, 25, 128, 512]) + def test_result_always_within_bounds(self, area): + plugin = make_plugin(title='Some Episode Title', subtitle='Series') + width = plugin._content_width(area) + assert 1 <= width <= area