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 @@ -508,7 +508,7 @@
"last_updated": "2026-07-17",
"verified": true,
"screenshot": "",
"latest_version": "1.1.2"
"latest_version": "1.2.0"
},
{
"id": "news",
Expand Down
12 changes: 12 additions & 0 deletions plugins/ledmatrix-music/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

6 changes: 6 additions & 0 deletions plugins/ledmatrix-music/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 66 additions & 3 deletions plugins/ledmatrix-music/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion plugins/ledmatrix-music/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
148 changes: 148 additions & 0 deletions plugins/ledmatrix-music/test_progress_bar_width.py
Original file line number Diff line number Diff line change
@@ -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
Loading