diff --git a/plugins.json b/plugins.json index 271d3940..94a1d773 100644 --- a/plugins.json +++ b/plugins.json @@ -508,7 +508,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.1.2" + "latest_version": "1.2.0" }, { "id": "news", diff --git a/plugins/ledmatrix-music/CHANGELOG.md b/plugins/ledmatrix-music/CHANGELOG.md new file mode 100644 index 00000000..68464444 --- /dev/null +++ b/plugins/ledmatrix-music/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## [1.2.0] - 2026-07-29 + +### Changed +- **Progress bar now matches the text width**: the bar spanned the whole text + area regardless of how much of it the text filled, so a short track title on + a wide panel left a bar stretching across the display. It is now sized to the + widest of the title, artist and album lines. A line long enough to scroll + still fills the bar, since that line genuinely fills the width. Disable with + `progress_bar_match_text` for the original behaviour. + diff --git a/plugins/ledmatrix-music/config_schema.json b/plugins/ledmatrix-music/config_schema.json index 779e85ff..b2fb081b 100644 --- a/plugins/ledmatrix-music/config_schema.json +++ b/plugins/ledmatrix-music/config_schema.json @@ -23,6 +23,12 @@ "default": "classic", "description": "Layout engine. 'classic' is the original fixed-size layout (unchanged). 'adaptive' (beta) scales the title/artist/album fonts to the panel height, growing on large panels and shrinking gracefully on small ones — the album art already scales this way. Your customization fonts and y_percent offsets still apply in adaptive mode. Requires LEDMatrix core with the adaptive layout system; falls back to classic on older cores. Switch back to 'classic' at any time to restore the original rendering." }, + "progress_bar_match_text": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Size the progress bar to the widest of the title, artist and album lines instead of stretching it across the whole text area. On a wide panel a short title otherwise leaves a bar spanning the display, which reads as a full-width element in Vegas scroll mode. A line long enough to scroll still fills the bar, since it genuinely fills that width. Turn off for the original full-width bar." + }, "preferred_source": { "type": "string", "description": "Preferred music source", diff --git a/plugins/ledmatrix-music/manager.py b/plugins/ledmatrix-music/manager.py index 8c350e68..80e95300 100644 --- a/plugins/ledmatrix-music/manager.py +++ b/plugins/ledmatrix-music/manager.py @@ -69,8 +69,12 @@ class MusicPlugin(BasePlugin): with album art, scrolling text, and progress bars. Supports both sources with automatic switching and seamless display updates. """ - - def __init__(self, plugin_id: str, config: Dict[str, Any], + + # 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 music plugin.""" super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) @@ -909,6 +913,57 @@ def update(self) -> None: if not self.poll_thread or not self.poll_thread.is_alive(): self.start_polling() + def _progress_bar_width(self, text_area_width, lines): + """Width for the progress bar, matched to the widest line of text. + + The bar used to span the whole text area regardless of how much of it + the text actually filled. With a short title on a wide panel that left + a bar stretching across the display under a few characters, which reads + as a full-width element in a ticker even after blank margins are + trimmed (the bar *is* ink, so there is nothing to trim). + + Sizing it to the widest of title/artist/album ties it to the content. + A line long enough to scroll measures wider than the area and so pins + the bar to full width — correct, because that line really does fill it. + + Set ``progress_bar_match_text`` false to restore the full-width bar. + + Args: + text_area_width: Space available for text, and the maximum width + lines: Iterable of (text, font) pairs, or None for a line that is + not currently drawn + + Returns: + Bar width in pixels, at least MIN_PROGRESS_BAR_WIDTH (or the whole + area if that is narrower) + """ + if not self.config.get('progress_bar_match_text', True): + return text_area_width + + widest = 0 + for line in lines: + if not line: + continue + text, font = line + if not text: + continue + try: + widest = max(widest, self.display_manager.get_text_width(text, font)) + except Exception: + # Font measurement is best-effort; a failure should not lose the + # progress bar entirely. + self.logger.debug( + "MusicPlugin: could not measure text for progress bar width", + exc_info=True, + ) + return text_area_width + + if widest <= 0: + return text_area_width + + floor = min(self.MIN_PROGRESS_BAR_WIDTH, text_area_width) + return max(floor, min(widest, text_area_width)) + def _clip_text_to_width(self, text, font, max_width): """Trim trailing characters so the rendered text fits within max_width px. @@ -1431,7 +1486,15 @@ def _safe_y_percent(value, fallback): progress_ms = current_track_info_snapshot.get('progress_ms', 0) if duration_ms > 0 and text_area_width > 0: - bar_total_width = text_area_width + album_shown = available_height_for_album >= album_height + bar_total_width = self._progress_bar_width( + text_area_width, + [ + (title, font_title), + (artist, font_artist), + (album, font_album) if album_shown else None, + ], + ) filled_ratio = progress_ms / duration_ms filled_width = int(filled_ratio * bar_total_width) diff --git a/plugins/ledmatrix-music/manifest.json b/plugins/ledmatrix-music/manifest.json index e075fe8a..83464f6e 100644 --- a/plugins/ledmatrix-music/manifest.json +++ b/plugins/ledmatrix-music/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-music", "name": "Music Player - Now Playing", - "version": "1.1.2", + "version": "1.2.0", "description": "Real-time now playing display for Spotify and YouTube Music with album art, scrolling text, and progress bars", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -66,6 +66,12 @@ } ], "versions": [ + { + "released": "2026-07-29", + "version": "1.2.0", + "notes": "Progress bar now matches the widest of the title/artist/album lines instead of always spanning the full text area, so a short track title no longer leaves a bar stretched across a wide panel. A line long enough to scroll still fills the bar. Disable with progress_bar_match_text.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "1.1.2", diff --git a/plugins/ledmatrix-music/test_progress_bar_width.py b/plugins/ledmatrix-music/test_progress_bar_width.py new file mode 100644 index 00000000..927973d8 --- /dev/null +++ b/plugins/ledmatrix-music/test_progress_bar_width.py @@ -0,0 +1,148 @@ +""" +Tests for MusicPlugin._progress_bar_width. + +The safety harness cannot cover this: its mock track has no duration, so the +progress bar is never drawn and the rendered extent is identical with and +without the change. These exercise the sizing rule directly. +""" + +import sys +import types + +import pytest + +# The plugin imports BasePlugin from the core, which is not on the path here. +# Stub just enough for the module to import, then test the method unbound. +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: # minimal stand-in + def __init__(self, *args, **kwargs): + pass + + base_plugin.BasePlugin = _BasePlugin + base_plugin.VegasDisplayMode = None + 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 + + +class FakeDisplayManager: + """Reports text width as a fixed number of pixels per character.""" + + def __init__(self, px_per_char=6, fail=False): + self.px_per_char = px_per_char + self.fail = fail + + def get_text_width(self, text, font=None): + if self.fail: + raise RuntimeError("font unavailable") + return len(text) * self.px_per_char + + +class FakeLogger: + def debug(self, *a, **k): + pass + + +def make_plugin(config=None, px_per_char=6, fail=False): + """Build a MusicPlugin shell without running its real __init__. + + The real __init__ starts polling threads and API clients; the progress-bar + sizing needs none of that. Subclassing with an empty __init__ is clearer + than __new__ gymnastics and keeps static analysis happy. + """ + from manager import MusicPlugin + + class _Shell(MusicPlugin): + def __init__(self): + pass + + plugin = _Shell() + plugin.config = config if config is not None else {} + plugin.display_manager = FakeDisplayManager(px_per_char, fail) + plugin.logger = FakeLogger() + return plugin + + +TEXT_AREA = 400 +FONT = object() + + +class TestProgressBarWidth: + def test_matches_the_widest_line(self): + plugin = make_plugin() + # artist is longest at 10 chars -> 60px + width = plugin._progress_bar_width(TEXT_AREA, [ + ('Song', FONT), # 24px + ('An Artist', FONT), # 54px + ('Alb', FONT), # 18px + ]) + assert width == 54 + + def test_short_title_no_longer_spans_the_panel(self): + # The reported problem: a few characters left a bar across the display. + plugin = make_plugin() + width = plugin._progress_bar_width(TEXT_AREA, [('Hey', FONT), ('Yo', FONT), None]) + assert width < TEXT_AREA + assert width == 24 # the MIN_PROGRESS_BAR_WIDTH floor + + def test_never_exceeds_the_text_area(self): + plugin = make_plugin() + # 200 chars would measure 1200px, far beyond the area. + width = plugin._progress_bar_width(TEXT_AREA, [('x' * 200, FONT)]) + assert width == TEXT_AREA + + def test_scrolling_line_pins_the_bar_to_full_width(self): + # A line long enough to scroll genuinely fills the width, so a + # full-width bar is correct there. + plugin = make_plugin() + assert plugin._progress_bar_width(TEXT_AREA, [('y' * 80, FONT)]) == TEXT_AREA + + def test_floor_is_applied(self): + plugin = make_plugin() + assert plugin._progress_bar_width(TEXT_AREA, [('a', FONT)]) == 24 + + def test_floor_cannot_exceed_a_narrow_text_area(self): + plugin = make_plugin() + assert plugin._progress_bar_width(10, [('a', FONT)]) == 10 + + def test_hidden_album_line_is_ignored(self): + plugin = make_plugin() + with_album = plugin._progress_bar_width( + TEXT_AREA, [('Song', FONT), ('Art', FONT), ('A Very Long Album', FONT)]) + without_album = plugin._progress_bar_width( + TEXT_AREA, [('Song', FONT), ('Art', FONT), None]) + assert with_album > without_album + + def test_empty_strings_are_skipped(self): + plugin = make_plugin() + assert plugin._progress_bar_width( + TEXT_AREA, [('', FONT), ('Four', FONT), None]) == 24 + + def test_all_lines_empty_falls_back_to_full_area(self): + plugin = make_plugin() + assert plugin._progress_bar_width(TEXT_AREA, [('', FONT), None]) == TEXT_AREA + + def test_disabled_by_config_restores_full_width(self): + plugin = make_plugin(config={'progress_bar_match_text': False}) + assert plugin._progress_bar_width(TEXT_AREA, [('Hey', FONT)]) == TEXT_AREA + + def test_enabled_by_default(self): + plugin = make_plugin(config={}) + assert plugin._progress_bar_width(TEXT_AREA, [('Hey', FONT)]) < TEXT_AREA + + def test_font_measurement_failure_keeps_the_bar(self): + # Losing the bar entirely would be worse than an over-wide one. + plugin = make_plugin(fail=True) + assert plugin._progress_bar_width(TEXT_AREA, [('Song', FONT)]) == TEXT_AREA + + @pytest.mark.parametrize('area', [1, 24, 25, 100, 512]) + def test_result_always_within_bounds(self, area): + plugin = make_plugin() + width = plugin._progress_bar_width(area, [('Some Title', FONT)]) + assert 1 <= width <= area