diff --git a/plugins.json b/plugins.json index 1b9aca0..a303d3f 100644 --- a/plugins.json +++ b/plugins.json @@ -478,10 +478,10 @@ "plugin_path": "plugins/ledmatrix-music", "stars": 0, "downloads": 0, - "last_updated": "2026-07-09", + "last_updated": "2026-07-10", "verified": true, "screenshot": "", - "latest_version": "1.0.8" + "latest_version": "1.1.1" }, { "id": "news", diff --git a/plugins/ledmatrix-music/README.md b/plugins/ledmatrix-music/README.md index d0b08e7..a6c2bb6 100644 --- a/plugins/ledmatrix-music/README.md +++ b/plugins/ledmatrix-music/README.md @@ -125,6 +125,31 @@ The music display shows: - **Progress Bar**: White progress bar at the bottom - **Nothing Playing**: Centered message when no music is detected +### Adaptive Layout (beta) + +Set `"layout_mode": "adaptive"` to scale the title/artist/album fonts to +your panel height — the same way the album art already scales +(`album_art_size = matrix_height`). On a tall panel the text grows right +along with the artwork instead of staying at a fixed size. + +```json +{ + "layout_mode": "adaptive" +} +``` + +- The default is `"classic"`: rendering is completely unchanged unless you + opt in. **To revert at any time, set it back to `"classic"`** — no + reinstall needed. +- Your font and vertical-position (`y_percent`) customizations still apply + in adaptive mode: an explicitly configured font/font_size for + `title_text`/`artist_text`/`album_text` wins over the automatic sizing. +- Since text still scrolls when it's too wide for the panel, fonts are + sized by height only (title/artist/album each get an equal share of the + vertical space above the progress bar) — width never forces the font + smaller the way it might on a scoreboard. +- Requires a LEDMatrix core with the adaptive layout system + (`docs/ADAPTIVE_LAYOUT.md`); older cores silently keep the classic layout. ## Music Sources @@ -287,6 +312,12 @@ This plugin is designed to work alongside other LEDMatrix plugins: ## Version History +### v1.1.0 +- Adaptive layout (beta, opt-in): `layout_mode: "adaptive"` scales title/ + artist/album fonts to the panel height, matching the album art's + existing height-based scaling. Default stays `"classic"`; revert anytime + by setting it back, no reinstall needed. + ### v1.0.0 - Initial plugin release - Migrated from `src/old_managers/music_manager.py` diff --git a/plugins/ledmatrix-music/config_schema.json b/plugins/ledmatrix-music/config_schema.json index 5ea3699..fe06de0 100644 --- a/plugins/ledmatrix-music/config_schema.json +++ b/plugins/ledmatrix-music/config_schema.json @@ -16,6 +16,12 @@ "maximum": 300, "default": 30 }, + "layout_mode": { + "type": "string", + "enum": ["classic", "adaptive"], + "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." + }, "preferred_source": { "type": "string", "description": "Preferred music source", diff --git a/plugins/ledmatrix-music/manager.py b/plugins/ledmatrix-music/manager.py index d0fa9a4..96583f5 100644 --- a/plugins/ledmatrix-music/manager.py +++ b/plugins/ledmatrix-music/manager.py @@ -25,6 +25,34 @@ # Import base plugin class from src.plugin_system.base_plugin import BasePlugin +# Adaptive layout system (opt-in via layout_mode: "adaptive"). Older +# LEDMatrix cores don't ship it — fall back silently to the classic layout. +# MusicPlugin is itself a BasePlugin, so it gets self.layout for free — +# unlike football-scoreboard's GameRenderer (a standalone helper class), +# no separate LayoutContext/FontManager wiring is needed here. +try: + from src.adaptive_layout import FontStep, LADDER_ARCADE, media_row + ADAPTIVE_AVAILABLE = True + # TTF-only, same verified-crisp rungs as the other adaptive plugins: + # PressStart2P only rasterizes without antialiasing at exact multiples + # of its 8px design grid; 4x6-font is crisp only at 7px (not 6, despite + # measuring the same ink height) — see measure_font_crispness. + ADAPTIVE_LADDER_TEXT = LADDER_ARCADE + ( + FontStep("4x6-font", 7), + ) +except ImportError: + ADAPTIVE_AVAILABLE = False + ADAPTIVE_LADDER_TEXT = None + +# Shared element-style resolver (newer cores): one implementation of font +# loading and the user-font-override check, referenced against this +# plugin's own config_schema.json. Older cores use the local code below. +try: + from src.element_style import ElementStyleResolver, defaults_from_schema_file + STYLE_AVAILABLE = True +except ImportError: + STYLE_AVAILABLE = False + # Configure logging logger = logging.getLogger(__name__) @@ -106,9 +134,20 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # Load configuration with flattened access self._load_config() self._initialize_clients() - + # Load custom fonts from config self._load_custom_fonts() + + # Adaptive layout (beta, opt-in): scales title/artist/album fonts to + # the panel instead of the classic fixed sizes. Default "classic" + # renders byte-identically to previous releases. + self.layout_mode = config.get('layout_mode', 'classic') + self._adaptive = ADAPTIVE_AVAILABLE and self.layout_mode == 'adaptive' + if self.layout_mode == 'adaptive' and not ADAPTIVE_AVAILABLE: + self.logger.warning( + "layout_mode 'adaptive' requires a LEDMatrix core with the " + "adaptive layout system; falling back to classic layout" + ) self.logger.info(f"Music plugin initialized - Source: {self.preferred_source}, Enabled: {self.enabled}, Live Priority: {self.config.get('live_priority', False)}") @@ -226,11 +265,26 @@ def _load_custom_fonts(self): self.logger.debug("No customization config found, using display_manager fonts") return + if STYLE_AVAILABLE: + resolver = self._get_element_style_resolver() + self.title_font = resolver.style( + 'title_text', classic_font='PressStart2P-Regular.ttf', + classic_size=8).font + self.artist_font = resolver.style( + 'artist_text', classic_font='PressStart2P-Regular.ttf', + classic_size=7).font + self.album_font = resolver.style( + 'album_text', classic_font='PressStart2P-Regular.ttf', + classic_size=7).font + self.logger.info("Loaded custom fonts via element-style resolver") + return + + # Older cores (no src.element_style): the original local loader. # Load fonts from config with defaults for backward compatibility title_config = customization.get('title_text', {}) artist_config = customization.get('artist_text', {}) album_config = customization.get('album_text', {}) - + try: self.title_font = self._load_custom_font_from_element_config(title_config, default_size=8) self.artist_font = self._load_custom_font_from_element_config(artist_config, default_size=7) @@ -239,6 +293,107 @@ def _load_custom_fonts(self): except Exception as e: self.logger.error(f"Error loading custom fonts: {e}, using display_manager fonts") + def _get_element_style_resolver(self): + """Shared resolver, referenced against this plugin's own + config_schema.json so the user-override check works in every context + (production, harness, dev server — no schema manager needed). + + NOTE: deliberately NOT BasePlugin.style_resolver — that property + sources defaults from plugin_manager.schema_manager, which is absent + under the test harness's mocks, and it caches on _style_resolver, + which this must not collide with. Rebuilt when the config dict is + swapped (on_config_change replaces self.config). + """ + resolver = getattr(self, '_element_style_resolver', None) + if resolver is None or resolver._config is not self.config: + schema_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') + resolver = ElementStyleResolver( + self.config, defaults_from_schema_file(schema_path)) + self._element_style_resolver = resolver + return resolver + + # (font filename, size) the config_schema.json declares as each + # element's default — matching these, not merely a key being *present*, + # is what "user set it" has to mean, because the web UI's save flow + # (schema_manager.merge_with_defaults) writes the FULL schema default + # object into config.json on every save, for every plugin, whether or + # not the user touched that section. Only used on older cores — with + # src.element_style available, the resolver reads the schema file itself. + _CLASSIC_FONT_DEFAULTS = { + 'title_text': ('PressStart2P-Regular.ttf', 8), + 'artist_text': ('5x7.bdf', 7), + 'album_text': ('5x7.bdf', 7), + } + + def _user_font_set(self, element_key: str) -> bool: + """True when the user's configured font/font_size for this element + genuinely differs from the schema default — adaptive mode must + respect a real override, but not a schema default that merely + happens to be present in a saved config.""" + if STYLE_AVAILABLE: + classic_font, classic_size = self._CLASSIC_FONT_DEFAULTS.get( + element_key, ('PressStart2P-Regular.ttf', 8)) + return self._get_element_style_resolver().style( + element_key, classic_font=classic_font, + classic_size=classic_size).user_forced + element_config = self.config.get('customization', {}).get(element_key, {}) + default_font, default_size = self._CLASSIC_FONT_DEFAULTS.get(element_key, (None, None)) + configured_font = element_config.get('font') + configured_size = element_config.get('font_size') + font_differs = configured_font is not None and configured_font != default_font + try: + size_differs = configured_size is not None and int(configured_size) != default_size + except (TypeError, ValueError): + size_differs = False + return font_differs or size_differs + + def _adaptive_text_layout(self, font_title, font_artist, font_album, + title_layout_config, artist_layout_config, album_layout_config, + text_area_x_start, text_area_width, matrix_height, + progress_bar_height, safe_y_percent): + """Adaptive font + Y-position pick for the title/artist/album rows. + + Splits the vertical space above the progress bar into three equal + rows and sizes each row's font to the largest crisp ladder rung + whose LINE HEIGHT fits that row — height-only, not width, because + long titles/artists/albums scroll rather than shrink to fit, the + same way the album art already scales by height alone + (album_art_size = matrix_height). User-forced fonts (an explicit + customization..font/font_size) win over auto-sizing, and + an explicit y_percent still overrides the row position, exactly + like classic mode. + """ + from src.adaptive_layout import Region + + top_margin = self.layout.px(1, minimum=1) + gap = self.layout.px(1, minimum=1) + available_h = max(3, matrix_height - progress_bar_height - top_margin - 1) + rows = Region(text_area_x_start, top_margin, text_area_width, available_h).split_v( + 1, 1, 1, gap=gap) + title_row, artist_row, album_row = rows + + def pick(element_key, classic_font, row): + if self._user_font_set(element_key): + return classic_font + return self.layout.font_for_rows(1, row.h, ladder=ADAPTIVE_LADDER_TEXT).font + + font_title = pick('title_text', font_title, title_row) + font_artist = pick('artist_text', font_artist, artist_row) + font_album = pick('album_text', font_album, album_row) + + y_title = title_row.y + y_artist = artist_row.y + y_album = album_row.y + if 'y_percent' in title_layout_config: + y_title = int(matrix_height * safe_y_percent(title_layout_config['y_percent'], y_title / matrix_height)) + if 'y_percent' in artist_layout_config: + y_artist = int(matrix_height * safe_y_percent(artist_layout_config['y_percent'], y_artist / matrix_height)) + if 'y_percent' in album_layout_config: + y_album = int(matrix_height * safe_y_percent(album_layout_config['y_percent'], y_album / matrix_height)) + + return font_title, font_artist, font_album, y_title, y_artist, y_album + def _initialize_clients(self): """Initialize music clients based on configuration.""" if not self.enabled: @@ -1012,30 +1167,44 @@ def _safe_y_percent(value, fallback): progress_bar_height = max(4, int(matrix_height * 0.06)) progress_bar_y = matrix_height - progress_bar_height - 1 - # Top-down stacking with proportional gaps - top_padding = max(1, matrix_height // 32) - line_gap = max(1, matrix_height // 16) - - # Auto-compute positions from font metrics - auto_y_title = top_padding - auto_y_artist = auto_y_title + title_height + line_gap - auto_y_album = auto_y_artist + artist_height + line_gap - - # Allow explicit y_percent override from user config - if 'y_percent' in title_layout_config: - y_pos_title_top = int(matrix_height * _safe_y_percent(title_layout_config['y_percent'], 0.03)) + if self._adaptive: + # Adaptive layout (beta, opt-in): font sizes scale with the + # panel instead of the classic fixed sizes; classic branch + # below is untouched when declined. + (font_title, font_artist, font_album, + y_pos_title_top, y_pos_artist_top, y_pos_album_top) = self._adaptive_text_layout( + font_title, font_artist, font_album, + title_layout_config, artist_layout_config, album_layout_config, + text_area_x_start, text_area_width, matrix_height, + progress_bar_height, _safe_y_percent) + title_height = self.display_manager.get_font_height(font_title) + artist_height = self.display_manager.get_font_height(font_artist) + album_height = self.display_manager.get_font_height(font_album) else: - y_pos_title_top = auto_y_title + # Top-down stacking with proportional gaps + top_padding = max(1, matrix_height // 32) + line_gap = max(1, matrix_height // 16) + + # Auto-compute positions from font metrics + auto_y_title = top_padding + auto_y_artist = auto_y_title + title_height + line_gap + auto_y_album = auto_y_artist + artist_height + line_gap + + # Allow explicit y_percent override from user config + if 'y_percent' in title_layout_config: + y_pos_title_top = int(matrix_height * _safe_y_percent(title_layout_config['y_percent'], 0.03)) + else: + y_pos_title_top = auto_y_title - if 'y_percent' in artist_layout_config: - y_pos_artist_top = int(matrix_height * _safe_y_percent(artist_layout_config['y_percent'], 0.34)) - else: - y_pos_artist_top = auto_y_artist + if 'y_percent' in artist_layout_config: + y_pos_artist_top = int(matrix_height * _safe_y_percent(artist_layout_config['y_percent'], 0.34)) + else: + y_pos_artist_top = auto_y_artist - if 'y_percent' in album_layout_config: - y_pos_album_top = int(matrix_height * _safe_y_percent(album_layout_config['y_percent'], 0.60)) - else: - y_pos_album_top = auto_y_album + if 'y_percent' in album_layout_config: + y_pos_album_top = int(matrix_height * _safe_y_percent(album_layout_config['y_percent'], 0.60)) + else: + y_pos_album_top = auto_y_album # Validate album doesn't overlap progress bar max_album_y = progress_bar_y - album_height - 1 diff --git a/plugins/ledmatrix-music/manifest.json b/plugins/ledmatrix-music/manifest.json index f494ec7..4aac83c 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.0.8", + "version": "1.1.1", "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-10", + "version": "1.1.0", + "ledmatrix_min_version": "2.0.0", + "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale title/artist/album fonts to the panel height, matching how the album art already scales. Default stays \"classic\" -- rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. User-configured fonts and y_percent offsets still apply in adaptive mode." + }, { "released": "2026-07-09", "version": "1.0.8", @@ -104,9 +110,15 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-09", + "last_updated": "2026-07-10", "stars": 0, "downloads": 0, "verified": true, - "screenshot": "" + "screenshot": "", + "display": { + "design_size": { + "width": 128, + "height": 32 + } + } } diff --git a/plugins/ledmatrix-music/test_adaptive_layout_mode.py b/plugins/ledmatrix-music/test_adaptive_layout_mode.py new file mode 100644 index 0000000..06dcc86 --- /dev/null +++ b/plugins/ledmatrix-music/test_adaptive_layout_mode.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Regression tests for the opt-in adaptive layout mode (layout_mode: "adaptive"). + +Guards the same two promises as football-scoreboard's and text-display's +adaptive modes: +1. CLASSIC IS UNTOUCHED — with the default config (layout_mode absent or + "classic") the renderer takes the classic path and renders byte-identically + to a renderer that has never heard of adaptive layout. +2. ADAPTIVE SCALES + RESPECTS CUSTOMIZATION — title/artist/album fonts grow + with the panel (height-driven, matching how the album art itself already + scales), user-configured fonts win over the ladder, and every ladder rung + renders with zero antialiasing. + +Run from the core LEDMatrix tree (needs src.* and assets/fonts): + cd /path/to/LEDMatrix + python -m pytest /path/to/ledmatrix-music/test_adaptive_layout_mode.py -q +""" + +import os +import sys + +import pytest +from PIL import ImageChops + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from manager import ADAPTIVE_AVAILABLE, ADAPTIVE_LADDER_TEXT, MusicPlugin # noqa: E402 + +pytestmark = pytest.mark.skipif( + not ADAPTIVE_AVAILABLE, + reason="core without src.adaptive_layout — adaptive mode falls back to classic", +) + +SIZES = [(64, 32), (128, 32), (96, 48), (128, 64), (256, 128)] + + +def _plugin(w, h, config=None): + from src.plugin_system.testing import ( + MockCacheManager, MockPluginManager, VisualTestDisplayManager, + ) + dm = VisualTestDisplayManager(w, h) + cfg = {"enabled": False, "preferred_source": "spotify"} + cfg.update(config or {}) + p = MusicPlugin("ledmatrix-music", cfg, dm, MockCacheManager(), MockPluginManager()) + p.current_track_info = { + "title": "A Song Title", + "artist": "An Artist Name", + "album": "An Album Name", + "album_art_url": None, + "duration_ms": 200000, + "progress_ms": 60000, + "is_playing": True, + } + p.is_music_display_active = True + return p + + +class TestClassicUntouched: + @pytest.mark.parametrize("config", [{}, {"layout_mode": "classic"}]) + def test_default_takes_classic_path(self, config): + p = _plugin(128, 32, config) + assert not p._adaptive + + @pytest.mark.parametrize("w,h", SIZES) + def test_classic_render_byte_identical(self, w, h): + """A default plugin must render the same pixels as one explicitly + set to classic — proving the adaptive changes are inert by default.""" + baseline = _plugin(w, h, {}) + baseline.display(force_clear=True) + classic = _plugin(w, h, {"layout_mode": "classic"}) + classic.display(force_clear=True) + assert ImageChops.difference( + baseline.display_manager.image, classic.display_manager.image + ).getbbox() is None + + +class TestAdaptiveMode: + def test_adaptive_flag(self): + assert _plugin(128, 32, {"layout_mode": "adaptive"})._adaptive + + @pytest.mark.parametrize("w,h", SIZES) + def test_renders_without_error(self, w, h): + p = _plugin(w, h, {"layout_mode": "adaptive"}) + p.display(force_clear=True) # must not raise + assert p.display_manager.image.size == (w, h) + + @staticmethod + def _title_row_ink_height(plugin): + """Measure the rendered title row's actual ink height — a direct + check of what display() drew, not a re-derivation of the sizing + logic (which could pass even if the render path were wired wrong).""" + img = plugin.display_manager.image + h = plugin.display_manager.matrix.height + art_size = h + title_band = img.crop((art_size + 2, 0, img.width, h // 3 + 2)) + lit = title_band.convert("L").point(lambda v: 255 if v > 30 else 0) + bbox = lit.getbbox() + return (bbox[3] - bbox[1]) if bbox else 0 + + def test_title_font_grows_with_height(self): + """128x64 and 256x128 (height 2x/4x the 128x32 design size) must + render a visibly taller title than 128x32 — the same height-driven + growth the album art (album_art_size = matrix_height) already has.""" + track = {"title": "SONGTITLE", "artist": "ARTISTNAME", "album": "ALBUMNAME", + "album_art_url": None, "duration_ms": 200000, "progress_ms": 60000, + "is_playing": True} + heights = {} + for w, h in [(128, 32), (128, 64), (256, 128)]: + p = _plugin(w, h, {"layout_mode": "adaptive"}) + p.current_track_info = track + p.display(force_clear=True) + heights[h] = self._title_row_ink_height(p) + assert heights[64] > heights[32] + assert heights[128] > heights[64] + + def test_user_font_wins_over_ladder(self): + """An explicitly configured title font must be used verbatim, even + in adaptive mode — 12px genuinely differs from the classic default + (8px), so this must register as a real user override.""" + cfg = {"layout_mode": "adaptive", + "customization": {"title_text": {"font_size": 12}}} + p = _plugin(256, 128, cfg) + assert p._user_font_set('title_text') + p.display(force_clear=True) # must not raise, must not crash + + def test_schema_default_is_not_mistaken_for_a_user_override(self): + """The web UI's save flow (schema_manager.merge_with_defaults) + writes the FULL schema default object into config.json on every + save — for every plugin, whether or not the user touched that + section. A config carrying only the schema's own declared defaults + must NOT be treated as a forced override, or adaptive sizing would + never engage on any config that's ever been saved once.""" + cfg = {"layout_mode": "adaptive", "customization": { + "title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8}, + "artist_text": {"font": "5x7.bdf", "font_size": 7}, + "album_text": {"font": "5x7.bdf", "font_size": 7}, + }} + p = _plugin(256, 128, cfg) + assert not p._user_font_set('title_text') + assert not p._user_font_set('artist_text') + assert not p._user_font_set('album_text') + + def test_falls_back_to_classic_without_core_support(self, monkeypatch): + import manager as music_manager + monkeypatch.setattr(music_manager, "ADAPTIVE_AVAILABLE", False) + p = _plugin(128, 32, {"layout_mode": "adaptive"}) + assert not p._adaptive + + +class TestElementStyleResolver: + """The shared core resolver (src.element_style) must actually be wired + in — a silently failing import would fall back to the local loader and + quietly reintroduce the per-plugin drift this migration removed.""" + + def test_resolver_is_active(self): + import manager as music_manager + assert music_manager.STYLE_AVAILABLE, \ + "src.element_style import failed on this core" + p = _plugin(128, 32, {"customization": { + "title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8}}}) + assert p._get_element_style_resolver() is not None + + def test_resolver_reads_schema_defaults(self): + """Schema-populated saved config is not a user override; a genuine + change is — resolved against this plugin's own config_schema.json, + which works even here where MockPluginManager has no schema + manager.""" + p = _plugin(128, 32, {"customization": { + "artist_text": {"font": "5x7.bdf", "font_size": 7}}}) + assert not p._user_font_set("artist_text") + p2 = _plugin(128, 32, {"customization": { + "artist_text": {"font": "5x7.bdf", "font_size": 9}}}) + assert p2._user_font_set("artist_text") + + def test_resolver_rebuilds_when_config_swapped(self): + """on_config_change replaces self.config with a new dict — the + cached resolver must not keep answering from the old one.""" + p = _plugin(128, 32, {"customization": { + "artist_text": {"font": "5x7.bdf", "font_size": 7}}}) + assert not p._user_font_set("artist_text") + new_config = dict(p.config) + new_config["customization"] = { + "artist_text": {"font": "5x7.bdf", "font_size": 9}} + p.on_config_change(new_config) + assert p._user_font_set("artist_text") + + +class TestLadderCrispness: + """Every rung in the adaptive ladder must render with zero antialiasing — + see LEDMatrix core's measure_font_crispness for why this matters.""" + + def test_ladder_is_crisp(self): + from src.adaptive_layout import measure_font_crispness + from src.font_manager import FontManager + + fm = FontManager({}) + offenders = [] + for step in ADAPTIVE_LADDER_TEXT: + font = fm.get_font(step.family, step.size_px) + c = measure_font_crispness(font, "A Song Title") + if c > 0.02: + offenders.append(f"{step.family}@{step.size_px}px: {c:.1%} antialiased") + assert not offenders, "Blurry rung(s):\n " + "\n ".join(offenders) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"]))