Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions plugins/jellyfin-now-playing/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

6 changes: 6 additions & 0 deletions plugins/jellyfin-now-playing/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the duplicated “drawn pixels” wording.

  • plugins/jellyfin-now-playing/config_schema.json#L51-L51: replace “drawn pixels” with “drawn as pixels”.
  • plugins/jellyfin-now-playing/CHANGELOG.md#L9-L9: apply the same wording correction.
📍 Affects 2 files
  • plugins/jellyfin-now-playing/config_schema.json#L51-L51 (this comment)
  • plugins/jellyfin-now-playing/CHANGELOG.md#L9-L9
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/jellyfin-now-playing/config_schema.json` at line 51, Replace “drawn
pixels” with “drawn as pixels” in the description at
plugins/jellyfin-now-playing/config_schema.json:51-51 and apply the same wording
correction at plugins/jellyfin-now-playing/CHANGELOG.md:9-9.

},
"show_progress": {
"type": "boolean",
"default": true,
Expand Down
51 changes: 48 additions & 3 deletions plugins/jellyfin-now-playing/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -523,21 +529,60 @@ 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],
fill=color)

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
Expand Down
8 changes: 7 additions & 1 deletion plugins/jellyfin-now-playing/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
116 changes: 116 additions & 0 deletions plugins/jellyfin-now-playing/test_content_width.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +19 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Isolate the plugin import from the global module cache.

Line 42 can import another plugin’s already-cached bare manager module, while Lines 19-33 permanently replace src modules for the rest of the test process. Load this plugin’s manager.py by a unique importlib module name/path and scope the fake src entries with patch.dict(sys.modules, ...) only during that import.

Otherwise collection order across plugins can make these tests exercise the wrong class or contaminate later core-plugin tests.

As per coding guidelines, top-level Python modules are loaded by bare name; tests must not rely on that shared module name across plugins.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/jellyfin-now-playing/test_content_width.py` around lines 19 - 42,
Update the test module setup around make_plugin to load this plugin’s manager.py
via importlib under a unique module name/path instead of importing bare manager.
Replace the permanent sys.modules assignments for src, src.plugin_system, and
src.plugin_system.base_plugin with patch.dict(sys.modules, ..., clear=False)
scoped only around that import, then obtain JellyfinNowPlayingPlugin from the
uniquely loaded module.

Source: Coding guidelines


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
Loading