diff --git a/custom_components/my_music_library/__init__.py b/custom_components/my_music_library/__init__.py index 927efddf..d1241c18 100644 --- a/custom_components/my_music_library/__init__.py +++ b/custom_components/my_music_library/__init__.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.loader import async_get_integration -from .api import ImageProxyView, MAQueueView, MusicAssistantBrowseView, MusicAssistantLibraryView, MusicAssistantProvidersView, MusicAssistantRecommendationsView, MusicAssistantSearchView, MusicAssistantSubitemsView, PlayerGroupView, PlayerQueueJumpView, PlayerQueueView +from .api import ImageProxyView, MAThumbnailView, MAQueueView, MusicAssistantBrowseView, MusicAssistantLibraryView, MusicAssistantProvidersView, MusicAssistantRecommendationsView, MusicAssistantSearchView, MusicAssistantSubitemsView, PlayerGroupView, PlayerQueueJumpView, PlayerQueueView from .const import CARD_JS_FILENAME, CARD_URL, CONF_DEBUG_MODE, CONF_EXCLUDED_PLAYERS, CONF_MA_URL, DOMAIN, ICON_URL, MUSIC_ASSISTANT_DOMAIN, WS_CONFIG_COMMAND _LOGGER = logging.getLogger(__name__) @@ -110,6 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.http.register_view(MusicAssistantRecommendationsView) hass.http.register_view(MusicAssistantProvidersView) hass.http.register_view(ImageProxyView) + hass.http.register_view(MAThumbnailView) # Register WebSocket command so the card can fetch its config _register_websocket_commands(hass) diff --git a/custom_components/my_music_library/__pycache__/__init__.cpython-314.pyc b/custom_components/my_music_library/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 69459e03..00000000 Binary files a/custom_components/my_music_library/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/custom_components/my_music_library/__pycache__/api.cpython-314.pyc b/custom_components/my_music_library/__pycache__/api.cpython-314.pyc deleted file mode 100644 index 416b1003..00000000 Binary files a/custom_components/my_music_library/__pycache__/api.cpython-314.pyc and /dev/null differ diff --git a/custom_components/my_music_library/__pycache__/config_flow.cpython-314.pyc b/custom_components/my_music_library/__pycache__/config_flow.cpython-314.pyc deleted file mode 100644 index 139f19e9..00000000 Binary files a/custom_components/my_music_library/__pycache__/config_flow.cpython-314.pyc and /dev/null differ diff --git a/custom_components/my_music_library/__pycache__/const.cpython-314.pyc b/custom_components/my_music_library/__pycache__/const.cpython-314.pyc deleted file mode 100644 index d961fc20..00000000 Binary files a/custom_components/my_music_library/__pycache__/const.cpython-314.pyc and /dev/null differ diff --git a/custom_components/my_music_library/api.py b/custom_components/my_music_library/api.py index 9b1c204d..424c5290 100644 --- a/custom_components/my_music_library/api.py +++ b/custom_components/my_music_library/api.py @@ -7,6 +7,7 @@ import enum import logging from http import HTTPStatus from typing import Any +from urllib.parse import quote import aiohttp from aiohttp import web @@ -69,6 +70,18 @@ def _extract_thumbnail(item: dict) -> str: return "" +def _make_thumb_url(raw_path: str) -> str: + """Wrap a raw image path in our thumbnail proxy endpoint. + + Instead of exposing raw image URLs (which may require provider auth + or be unreachable from the browser), route everything through our + server-side proxy that resolves via the MA server. + """ + if not raw_path: + return "" + return f"/my_music_library/thumb?path={quote(raw_path, safe='')}" + + def _serialize_search_results(results: Any) -> dict: """Convert a MA SearchResults object (or dict) to a plain dict.""" if results is None: @@ -77,10 +90,9 @@ def _serialize_search_results(results: Any) -> dict: if isinstance(safe, dict): for key in ("tracks", "artists", "albums", "playlists", "radios"): for item in safe.get(key) or []: - if isinstance(item, dict) and not item.get("thumbnail"): - thumb = _extract_thumbnail(item) - if thumb: - item["thumbnail"] = thumb + if isinstance(item, dict): + raw = item.get("thumbnail") or _extract_thumbnail(item) + item["thumbnail"] = _make_thumb_url(raw) return safe return {"tracks": [], "artists": [], "albums": [], "playlists": [], "raw": str(safe)} @@ -242,7 +254,7 @@ def _normalize_library_item(item: dict) -> dict: if isinstance(media_type, dict): media_type = media_type.get("value", "") - thumbnail = _extract_thumbnail(item) + thumbnail = _make_thumb_url(_extract_thumbnail(item)) artist = item.get("media_artist") or "" if not artist: @@ -554,7 +566,7 @@ def _normalize_browse_item(item: dict) -> dict: if _path.startswith("folder/"): uri = f"{_scheme}://{_path[len('folder/'):]}" - thumbnail = _extract_thumbnail(item) + thumbnail = _make_thumb_url(_extract_thumbnail(item)) artist = item.get("media_artist") or "" if not artist: @@ -680,6 +692,45 @@ def _parse_ma_uri(uri: str) -> tuple[str, str]: return item_id, scheme +_SUBITEM_REST_PATHS: dict[str, list[str]] = { + "artist_albums": ["/api/music/artists/{id}/albums", "/api/artists/{id}/albums"], + "album_tracks": ["/api/music/albums/{id}/tracks", "/api/albums/{id}/tracks"], + "playlist_tracks": ["/api/music/playlists/{id}/tracks", "/api/playlists/{id}/tracks"], +} + + +async def _get_subitems_via_rest( + hass: HomeAssistant, + action: str, + item_id: str, + limit: int, +) -> list | None: + """Fallback: fetch sub-items via MA REST API.""" + ma_url = _get_mass_url(hass) + if not ma_url: + return None + + session = async_get_clientsession(hass) + paths = _SUBITEM_REST_PATHS.get(action, []) + for path_tpl in paths: + url = f"{ma_url}{path_tpl.format(id=item_id)}" + try: + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status != 200: + _LOGGER.debug("Subitems REST %s → HTTP %s", url, resp.status) + continue + data = await resp.json(content_type=None) + items_raw = data if isinstance(data, list) else (data.get("items") or []) + _LOGGER.info("Subitems REST %s → %d items", url, len(items_raw)) + return [_normalize_library_item(i) for i in items_raw[:limit]] + except Exception as err: # noqa: BLE001 + _LOGGER.debug("Subitems REST %s failed: %s", url, err) + return None + + async def _get_subitems( hass: HomeAssistant, action: str, @@ -690,11 +741,11 @@ async def _get_subitems( mass = _get_mass_client(hass) if mass is None: _LOGGER.warning("No Music Assistant (mass) client available for subitems") - return None + return await _get_subitems_via_rest(hass, action, uri, limit) music = getattr(mass, "music", None) if not music: - return None + return await _get_subitems_via_rest(hass, action, uri, limit) methods = _SUBITEM_METHODS.get(action, []) if not methods: @@ -723,10 +774,11 @@ async def _get_subitems( _LOGGER.info("Subitems %s: %d items (args=%s kwargs=%s)", action, len(items), call_args, call_kwargs) return [_normalize_library_item(_to_json_safe(i)) for i in items[:limit]] except Exception as err: # noqa: BLE001 - _LOGGER.debug("Subitems %s args=%s kwargs=%s → %s: %s", method_name, call_args, call_kwargs, type(err).__name__, err) + _LOGGER.warning("Subitems %s args=%s kwargs=%s → %s: %s", method_name, call_args, call_kwargs, type(err).__name__, err) continue - return None + _LOGGER.warning("Subitems %s: all MA client attempts failed for %r, trying REST fallback", action, uri) + return await _get_subitems_via_rest(hass, action, item_id, limit) # ── Providers ───────────────────────────────────────────────────────────────── @@ -853,9 +905,10 @@ def _normalize_queue_item(item: dict) -> dict: queue_item_id = item.get("queue_item_id") or "" duration = item.get("duration") or 0 - thumbnail = _extract_thumbnail(item) - if not thumbnail: - thumbnail = _extract_thumbnail(item.get("media_item") or {}) + raw_thumb = _extract_thumbnail(item) + if not raw_thumb: + raw_thumb = _extract_thumbnail(item.get("media_item") or {}) + thumbnail = _make_thumb_url(raw_thumb) media_item = item.get("media_item") or {} uri = media_item.get("uri") or "" @@ -1361,3 +1414,64 @@ class ImageProxyView(HomeAssistantView): ) except Exception: # noqa: BLE001 return web.Response(status=HTTPStatus.BAD_GATEWAY) + + +class MAThumbnailView(HomeAssistantView): + """Proxy thumbnail requests through the MA server. + + GET /my_music_library/thumb?path= + + Resolves image paths server-side via the Music Assistant server, + handling provider-specific auth (Plex tokens, etc.) and internal + MA references that browsers cannot access directly. + """ + + url = "/my_music_library/thumb" + name = "my_music_library:thumb" + requires_auth = False + + async def get(self, request: web.Request) -> web.Response: + """Resolve an image path via MA and return the image bytes.""" + hass: HomeAssistant = request.app["hass"] + raw_path = request.query.get("path", "").strip() + if not raw_path: + return web.Response(status=HTTPStatus.BAD_REQUEST) + + ma_url = _get_mass_url(hass) + session = async_get_clientsession(hass) + + urls_to_try: list[str] = [] + if raw_path.startswith("http"): + urls_to_try.append(raw_path) + if ma_url: + urls_to_try.append( + f"{ma_url}/api/image/{quote(raw_path, safe='')}" + ) + elif ma_url: + urls_to_try.append( + f"{ma_url}/api/image/{quote(raw_path, safe='')}" + ) + if raw_path.startswith("/"): + urls_to_try.append(f"{ma_url}{raw_path}") + + for image_url in urls_to_try: + try: + async with session.get( + image_url, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status != 200: + continue + ct = resp.content_type or "image/jpeg" + if not ct.startswith("image"): + continue + body = await resp.read() + return web.Response( + body=body, + content_type=ct, + headers={"Cache-Control": "public, max-age=3600"}, + ) + except Exception: # noqa: BLE001 + continue + + return web.Response(status=HTTPStatus.NOT_FOUND) diff --git a/custom_components/my_music_library/manifest.json b/custom_components/my_music_library/manifest.json index 068d08b4..a17ce954 100644 --- a/custom_components/my_music_library/manifest.json +++ b/custom_components/my_music_library/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues", "requirements": [], - "version": "3.10.4" + "version": "3.12.4" } diff --git a/custom_components/my_music_library/www/my-music-library-card.js b/custom_components/my_music_library/www/my-music-library-card.js index 73579b49..64162228 100644 --- a/custom_components/my_music_library/www/my-music-library-card.js +++ b/custom_components/my_music_library/www/my-music-library-card.js @@ -5,7 +5,7 @@ * @version 1.0.0 */ -const CARD_VERSION = "3.10.4"; +const CARD_VERSION = "3.12.4"; /* ─── Icons (inline SVG strings) ─────────────────────────── */ const ICONS = { @@ -169,6 +169,32 @@ const TRANSLATIONS = { search_layout_rows: "Rows", search_layout_columns: "Columns", show_device_select: "Show device selection", + type_custom_element: "Custom element", + btn_element_name: "Element tag", + btn_element_config: "Config (JSON)", + action_mml_navigate_tab: "Show tab (MML)", + action_mml_navigate_section: "Show section (MML)", + action_mml_control: "Player control (MML)", + btn_mml_tab: "Tab", + btn_mml_section: "Section", + btn_mml_command: "Command", + mml_cmd_play_pause: "Play / Pause", + mml_cmd_next: "Next", + mml_cmd_prev: "Previous", + mml_cmd_shuffle: "Shuffle", + mml_cmd_repeat: "Repeat", + mml_cmd_mute: "Mute", + nav_bar_section: "Navigation bar", + nav_bar_position: "Position", + nav_bar_pos_top: "Top", + nav_bar_pos_bottom: "Bottom", + nav_bar_pos_left: "Left", + nav_bar_pos_right: "Right", + nav_bar_align: "Alignment", + nav_bar_align_start: "Start", + nav_bar_align_center: "Center", + nav_bar_align_end: "End", + nav_bar_align_space_between: "Space between", }, }, fr: { @@ -290,6 +316,32 @@ const TRANSLATIONS = { search_layout_rows: "Lignes", search_layout_columns: "Colonnes", show_device_select: "Afficher la sélection de l'appareil", + type_custom_element: "Élément custom", + btn_element_name: "Balise élément", + btn_element_config: "Config (JSON)", + action_mml_navigate_tab: "Afficher un onglet (MML)", + action_mml_navigate_section: "Afficher une section (MML)", + action_mml_control: "Contrôle lecteur (MML)", + btn_mml_tab: "Onglet", + btn_mml_section: "Section", + btn_mml_command: "Commande", + mml_cmd_play_pause: "Lecture / Pause", + mml_cmd_next: "Suivant", + mml_cmd_prev: "Précédent", + mml_cmd_shuffle: "Aléatoire", + mml_cmd_repeat: "Répéter", + mml_cmd_mute: "Muet", + nav_bar_section: "Barre de navigation", + nav_bar_position: "Position", + nav_bar_pos_top: "Haut", + nav_bar_pos_bottom: "Bas", + nav_bar_pos_left: "Gauche", + nav_bar_pos_right: "Droite", + nav_bar_align: "Alignement", + nav_bar_align_start: "Début", + nav_bar_align_center: "Centre", + nav_bar_align_end: "Fin", + nav_bar_align_space_between: "Réparti", }, }, de: { @@ -411,10 +463,120 @@ const TRANSLATIONS = { search_layout_rows: "Zeilen", search_layout_columns: "Spalten", show_device_select: "Geräteauswahl anzeigen", + type_custom_element: "Benutzerelement", + btn_element_name: "Element-Tag", + btn_element_config: "Konfiguration (JSON)", + action_mml_navigate_tab: "Tab anzeigen (MML)", + action_mml_navigate_section: "Abschnitt anzeigen (MML)", + action_mml_control: "Player-Steuerung (MML)", + btn_mml_tab: "Tab", + btn_mml_section: "Abschnitt", + btn_mml_command: "Befehl", + mml_cmd_play_pause: "Wiedergabe / Pause", + mml_cmd_next: "Weiter", + mml_cmd_prev: "Zurück", + mml_cmd_shuffle: "Zufällig", + mml_cmd_repeat: "Wiederholen", + mml_cmd_mute: "Stummschalten", + nav_bar_section: "Navigationsleiste", + nav_bar_position: "Position", + nav_bar_pos_top: "Oben", + nav_bar_pos_bottom: "Unten", + nav_bar_pos_left: "Links", + nav_bar_pos_right: "Rechts", + nav_bar_align: "Ausrichtung", + nav_bar_align_start: "Anfang", + nav_bar_align_center: "Mitte", + nav_bar_align_end: "Ende", + nav_bar_align_space_between: "Verteilt", }, }, }; +/* ─── YAML utilities (no external deps) ──────────────────── */ +function _yamlDump(obj, indent = 0) { + if (obj === null || obj === undefined) return "null"; + if (typeof obj === "boolean" || typeof obj === "number") return String(obj); + if (typeof obj === "string") { + if (!obj || /[\r\n:#\[\]{},&*?|<>=!%@`"']/.test(obj) || /^\s|\s$/.test(obj) || + /^(true|false|yes|no|on|off|null|~)$/i.test(obj) || /^\d/.test(obj)) { + return `"${obj.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`; + } + return obj; + } + if (Array.isArray(obj)) { + if (!obj.length) return "[]"; + const pad = " ".repeat(indent); + return obj.map(v => `${pad}- ${_yamlDump(v, indent + 2)}`).join("\n"); + } + if (typeof obj === "object") { + const keys = Object.keys(obj); + if (!keys.length) return ""; + const pad = " ".repeat(indent); + return keys.map(k => { + const v = obj[k]; + if (v !== null && typeof v === "object") { + const nested = _yamlDump(v, indent + 2); + return nested ? `${pad}${k}:\n${nested}` : `${pad}${k}: {}`; + } + return `${pad}${k}: ${_yamlDump(v, indent)}`; + }).join("\n"); + } + return String(obj); +} + +function _parseYamlScalar(v) { + if (v === "true" || v === "yes" || v === "on") return true; + if (v === "false" || v === "no" || v === "off") return false; + if (v === "null" || v === "~" || v === "") return null; + if (/^-?\d+$/.test(v)) return parseInt(v, 10); + if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + return v.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"'); + } + return v; +} + +function _yamlLoad(text) { + if (window.jsyaml?.load) return window.jsyaml.load(text); + const t = text.trim(); + if (!t || t === "{}") return {}; + // Basic line-by-line parser for HA card config subset + const lines = t.split("\n"); + const stack = [{ obj: {}, indent: -1 }]; + const arrKeys = new Map(); // tracks which keys hold arrays + for (const raw of lines) { + const trimEnd = raw.trimEnd(); + if (!trimEnd || trimEnd.trimStart().startsWith("#")) continue; + const il = trimEnd.length - trimEnd.trimStart().length; + const content = trimEnd.trimStart(); + while (stack.length > 1 && stack[stack.length - 1].indent >= il) stack.pop(); + const top = stack[stack.length - 1]; + if (content.startsWith("- ")) { + const val = _parseYamlScalar(content.slice(2).trim()); + const arrKey = arrKeys.get(top); + if (arrKey !== undefined && Array.isArray(top.obj[arrKey])) top.obj[arrKey].push(val); + continue; + } + const ci = content.indexOf(": "); + const colonEnd = content === content.replace(/:$/, "") ? -1 : content.length - 1; + if (ci === -1 && colonEnd === -1) continue; + const key = ci >= 0 ? content.slice(0, ci).trim() : content.slice(0, colonEnd).trim(); + const val = ci >= 0 ? content.slice(ci + 2).trim() : ""; + if (!val) { + const newObj = {}; + top.obj[key] = newObj; + stack.push({ obj: newObj, indent: il }); + } else if (val === "[]") { + top.obj[key] = []; + arrKeys.set(stack[stack.length - 1], key); + } else { + top.obj[key] = _parseYamlScalar(val); + } + } + return stack[0].obj; +} + /* ─── CSS ─────────────────────────────────────────────────── */ const STYLES = ` :host { @@ -545,6 +707,56 @@ const STYLES = ` .nav-btn ha-icon { --mdc-icon-size: 20px; display: block; pointer-events: none; } .nav-btn svg { width: 20px; height: 20px; fill: currentColor; flex-shrink: 0; } .nav-btn-label { font-size: 10px; font-weight: 500; line-height: 1; pointer-events: none; } + + /* ── CUSTOM ELEMENT SLOT ── */ + .nav-btn-custom { padding: 0; min-width: 36px; min-height: 44px; overflow: hidden; flex-shrink: 0; } + .nav-btn-custom > * { pointer-events: none; display: block; width: 100%; height: 100%; } + + /* ── NAV BAR POSITION VARIANTS ── */ + .card-root[data-nav-pos="bottom"] { flex-direction: column-reverse; } + .card-root[data-nav-pos="left"], + .card-root[data-nav-pos="right"] { flex-direction: row; } + .card-root[data-nav-pos="right"] { flex-direction: row-reverse; } + + .card-root[data-nav-pos="left"] .nav, + .card-root[data-nav-pos="right"] .nav { + flex-direction: column; + overflow-y: auto; overflow-x: hidden; + border-bottom: none; border-right: 1px solid var(--border); + scroll-snap-type: y mandatory; + width: auto; height: 100%; + } + .card-root[data-nav-pos="right"] .nav { border-right: none; border-left: 1px solid var(--border); } + + .card-root[data-nav-pos="left"] .nav-tabs, + .card-root[data-nav-pos="right"] .nav-tabs { flex-direction: column; flex: none; width: 100%; } + + .card-root[data-nav-pos="left"] .nav-tab, + .card-root[data-nav-pos="right"] .nav-tab { + border-right: none; border-bottom: none; + justify-content: flex-start; padding: 10px 14px; + border-left: 3px solid transparent; flex: none; + } + .card-root[data-nav-pos="right"] .nav-tab { border-left: none; border-right: 3px solid transparent; } + + .card-root[data-nav-pos="left"] .nav-tab.active { + border-left-color: var(--accent); border-bottom: none; + background: color-mix(in srgb, var(--accent) 10%, transparent); + } + .card-root[data-nav-pos="right"] .nav-tab.active { + border-right-color: var(--accent); border-bottom: none; + background: color-mix(in srgb, var(--accent) 10%, transparent); + } + .card-root[data-nav-pos="left"] .nav-fade-left, + .card-root[data-nav-pos="left"] .nav-fade-right, + .card-root[data-nav-pos="right"] .nav-fade-left, + .card-root[data-nav-pos="right"] .nav-fade-right { display: none; } + + /* ── NAV TABS ALIGNMENT ── */ + .nav-tabs[data-align="center"] { justify-content: center; } + .nav-tabs[data-align="end"] { justify-content: flex-end; } + .nav-tabs[data-align="space-between"] { justify-content: space-between; } + /* ── CONTENT AREA ── */ /* position:relative + inset:0 on children is the most reliable way to give tab panels a definite pixel height without relying on flex cross-axis @@ -1146,7 +1358,7 @@ const STYLES = ` /* ══════════════════════════════════════════ LIBRARY TAB ══════════════════════════════════════════ */ - .library-panel { flex: 1; overflow-y: auto; padding: 0 0 16px; display: flex; flex-direction: column; } + .library-panel { flex: 1; overflow: hidden; padding: 0 0 16px; display: flex; flex-direction: column; } .lib-filters { display: flex; @@ -1197,7 +1409,7 @@ const STYLES = ` .lib-filter-fav.active { background: var(--accent); color: #000; border-color: var(--accent); } .lib-filter-fav svg { width: 14px; height: 14px; fill: currentColor; } - .lib-content { flex: 1; overflow-y: auto; } + .lib-content { flex: 1; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; } .lib-section { margin-bottom: 8px; } .lib-section-header { @@ -1219,6 +1431,7 @@ const STYLES = ` scrollbar-width: none; position: relative; } + .lib-scroll.scroll-locked { overflow-x: hidden !important; } .lib-scroll::-webkit-scrollbar { display: none; } @media (hover: hover) and (pointer: fine) { .lib-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.2) transparent; } @@ -1815,6 +2028,12 @@ class MyMusicLibraryCard extends HTMLElement { const typeCounts = {}; return config.tabs.map(t => { const type = t.type || "button"; + if (type === "custom_element") { + return { type: "custom_element", id: `ce-${idx++}`, element: t.element || "", + element_config: t.element_config || {}, name: t.name || "", + tap_action: t.tap_action, hold_action: t.hold_action, double_tap_action: t.double_tap_action, + width: t.width, height: t.height }; + } if (type === "button") { return { type: "button", id: `btn-${idx++}`, icon: t.icon, name: t.name || "", entity: t.entity, tap_action: t.tap_action, hold_action: t.hold_action, double_tap_action: t.double_tap_action, @@ -1823,7 +2042,7 @@ class MyMusicLibraryCard extends HTMLElement { typeCounts[type] = (typeCounts[type] || 0) + 1; const id = type === "settings" ? "settings" : (typeCounts[type] > 1 ? `${type}-${typeCounts[type] - 1}` : type); const tab = { type, id, label: t.label || null, iconOverride: t.icon || null, - defaultIcon: TAB_ICONS[type] || null }; + defaultIcon: TAB_ICONS[type] || null, show_in_nav: t.show_in_nav !== false }; if (type === "library") { const sections = Array.isArray(t.sections) ? t.sections.filter(s => VALID_SECTIONS.includes(s)) : null; tab.sections = sections && sections.length ? sections : DEFAULT_SECTIONS; @@ -2068,8 +2287,10 @@ class MyMusicLibraryCard extends HTMLElement { const card = document.createElement("div"); card.className = `card-root${this._isMobile ? " mml-mobile" : ""}`; + const navPos = this._config.nav_bar?.position || "top"; + if (navPos !== "top") card.dataset.navPos = navPos; - const panels = this._resolvedTabs.filter(t => t.type !== "button"); + const panels = this._resolvedTabs.filter(t => t.type !== "button" && t.type !== "custom_element"); const panelRenderers = { player: (t) => this._renderPlayerTab(), search: (t) => this._renderSearchTab(), @@ -2107,8 +2328,20 @@ class MyMusicLibraryCard extends HTMLElement { this._updatePlayerContent(card); } + _renderCustomElementSlot(tab) { + const sizeParts = []; + if (tab.width) sizeParts.push(`width:${typeof tab.width === "number" ? tab.width + "px" : tab.width}`); + if (tab.height) sizeParts.push(`height:${typeof tab.height === "number" ? tab.height + "px" : tab.height}`); + const sizeStyle = sizeParts.length ? ` style="${sizeParts.join(";")}"` : ""; + return ``; + } + _renderNav() { - const items = this._resolvedTabs.map(t => { + const align = this._config.nav_bar?.align || "start"; + const items = this._resolvedTabs.filter(t => t.show_in_nav !== false).map(t => { + if (t.type === "custom_element") { + return this._renderCustomElementSlot(t); + } if (t.type === "button") { return this._renderNavButton(t); } @@ -2131,12 +2364,14 @@ class MyMusicLibraryCard extends HTMLElement { `; }).join(""); + const alignAttr = align !== "start" ? ` data-align="${align}"` : ""; + const navStyle = this._config.nav_bar?.style ? ` style="${this._esc(this._config.nav_bar.style)}"` : ""; return ` `; } @@ -2511,6 +2746,27 @@ class MyMusicLibraryCard extends HTMLElement { }); }); + this._mountCustomElements(card); + } + + _mountCustomElements(card) { + card.querySelectorAll("[data-ce-slot]").forEach(slot => { + if (slot._ceMounted) return; + const id = slot.dataset.ceSlot; + const tab = this._resolvedTabs.find(t => t.id === id); + if (!tab?.element) return; + // Strip Lovelace "custom:" prefix — the actual DOM tag name never has it + const tagName = tab.element.replace(/^custom:/, ""); + const el = document.createElement(tagName); + if (typeof el.setConfig === "function") { + try { el.setConfig(tab.element_config || {}); } catch(e) { + console.warn(`[mml] custom_element "${tagName}" setConfig error:`, e); + } + } + if (this._hass) el.hass = this._hass; + slot.appendChild(el); + slot._ceMounted = true; + }); } /* ── Nav button action handler ── */ @@ -2577,6 +2833,75 @@ class MyMusicLibraryCard extends HTMLElement { })); break; } + case "mml_navigate_tab": { + const tabTypeOrId = action.tab; + if (!tabTypeOrId) break; + const card = this.shadowRoot?.querySelector(".card-root"); + if (!card) break; + const tabDef = this._resolvedTabs.find(t => t.id === tabTypeOrId) || + this._resolvedTabs.find(t => t.type === tabTypeOrId); + if (!tabDef) break; + if (tabDef.type === "settings") { + this._openSettings(card); + } else { + this._setActiveTab(tabDef.id, card); + if (tabDef.type === "library" && !this._libLoadedTabs.has(tabDef.id)) this._loadLibrary(); + } + break; + } + case "mml_navigate_section": { + const section = action.section; + if (!section) break; + const card = this.shadowRoot?.querySelector(".card-root"); + if (!card) break; + const libTab = this._resolvedTabs.find(t => t.type === "library" && t.sections?.includes(section)) || + this._resolvedTabs.find(t => t.type === "library"); + if (!libTab) break; + const alreadyLoaded = this._libLoadedTabs.has(libTab.id); + this._setActiveTab(libTab.id, card); + if (!alreadyLoaded) this._loadLibrary(); + setTimeout(() => { + const panel = card.querySelector(`[data-panel="${libTab.id}"]`); + const secEl = panel?.querySelector(`#lib-sec-${section}`); + if (secEl) secEl.scrollIntoView({ behavior: "smooth", block: "start" }); + }, alreadyLoaded ? 50 : 600); + break; + } + case "mml_control": { + const cmd = action.command; + const player = this._activePlayer; + if (!cmd || !player) break; + switch (cmd) { + case "play_pause": { + const state = this._hass?.states[player]?.state; + this._hass?.callService("media_player", state === "playing" ? "media_pause" : "media_play", {}, { entity_id: player }); + break; + } + case "next": + this._hass?.callService("media_player", "media_next_track", {}, { entity_id: player }); + break; + case "prev": + this._hass?.callService("media_player", "media_previous_track", {}, { entity_id: player }); + break; + case "shuffle": { + const shuffleOn = this._hass?.states[player]?.attributes?.shuffle; + this._hass?.callService("media_player", "shuffle_set", { shuffle: !shuffleOn }, { entity_id: player }); + break; + } + case "repeat": { + const cur = this._hass?.states[player]?.attributes?.repeat || "off"; + const next = cur === "off" ? "all" : cur === "all" ? "one" : "off"; + this._hass?.callService("media_player", "repeat_set", { repeat: next }, { entity_id: player }); + break; + } + case "mute": { + const isMuted = this._hass?.states[player]?.attributes?.is_volume_muted; + this._hass?.callService("media_player", "volume_mute", { is_volume_muted: !isMuted }, { entity_id: player }); + break; + } + } + break; + } default: break; } @@ -2601,12 +2926,17 @@ class MyMusicLibraryCard extends HTMLElement { _updateNavButtons(card) { for (const tab of this._resolvedTabs) { - if (tab.type !== "button" || !tab.entity) continue; - const st = this._hass?.states[tab.entity]; - const isActive = st ? ["on", "playing", "active", "home"].includes(st.state) : false; - const el = card.querySelector(`[data-tab-btn="${tab.id}"]`); - if (el) el.classList.toggle("active", isActive); + if (tab.type === "button" && tab.entity) { + const st = this._hass?.states[tab.entity]; + const isActive = st ? ["on", "playing", "active", "home"].includes(st.state) : false; + const el = card.querySelector(`[data-tab-btn="${tab.id}"]`); + if (el) el.classList.toggle("active", isActive); + } } + // Propagate hass updates to mounted custom elements + card.querySelectorAll("[data-ce-slot] > *").forEach(el => { + if (this._hass) el.hass = this._hass; + }); } _resolveImageUrl(url) { @@ -3183,6 +3513,12 @@ class MyMusicLibraryCard extends HTMLElement { return null; } + _makeThumbUrl(rawPath) { + if (!rawPath) return null; + if (rawPath.startsWith("/my_music_library/thumb")) return rawPath; + return `/my_music_library/thumb?path=${encodeURIComponent(rawPath)}`; + } + /* Parse the result of music_assistant/search WebSocket command. MA returns either a flat {tracks,artists,albums,playlists} or a nested {results: {tracks,...}}. */ @@ -3193,12 +3529,13 @@ class MyMusicLibraryCard extends HTMLElement { for (const [key, type] of Object.entries(map)) { const items = root[key] || []; for (const item of items) { + const rawThumb = item.thumbnail || item.metadata?.images?.[0]?.path || item.image?.path || (typeof item.image === "string" ? item.image : null) || null; out[key].push({ id: item.uri || item.item_id || "", type, title: item.name || item.title || "", subtitle: item.artists?.[0]?.name || item.artist?.name || item.owner_name || "", - thumbnail: item.thumbnail || item.metadata?.images?.[0]?.path || item.image?.path || (typeof item.image === "string" ? item.image : null) || null, + thumbnail: this._makeThumbUrl(rawThumb), can_play: true, }); } @@ -3831,6 +4168,38 @@ class MyMusicLibraryCard extends HTMLElement { } this._attachLibInfiniteScroll(libEl); + this._attachLibDirectionLock(libEl); + } + + _attachLibDirectionLock(libEl) { + if (!libEl || libEl._dirLockBound) return; + libEl._dirLockBound = true; + const THRESHOLD = 8; + let startX = 0, startY = 0, locked = null; + + libEl.addEventListener("touchstart", (e) => { + startX = e.touches[0].pageX; + startY = e.touches[0].pageY; + locked = null; + }, { passive: true }); + + libEl.addEventListener("touchmove", (e) => { + if (locked) return; + const dx = Math.abs(e.touches[0].pageX - startX); + const dy = Math.abs(e.touches[0].pageY - startY); + if (dx < THRESHOLD && dy < THRESHOLD) return; + locked = dy >= dx ? "v" : "h"; + if (locked === "v") { + libEl.querySelectorAll(".lib-scroll").forEach(el => el.classList.add("scroll-locked")); + } + }, { passive: true }); + + libEl.addEventListener("touchend", () => { + if (locked === "v") { + libEl.querySelectorAll(".lib-scroll").forEach(el => el.classList.remove("scroll-locked")); + } + locked = null; + }, { passive: true }); } _attachLibInfiniteScroll(libEl) { @@ -4573,6 +4942,14 @@ const EDITOR_STYLES = ` .add-tab-menu button:hover { border-color: var(--primary-color, #03a9f4); background: rgba(3,169,244,0.04); } .expand-chevron { transition: transform 0.2s; font-size: 12px; } .expand-chevron.open { transform: rotate(90deg); } + .tab-item-type.custom_element { background: #9c27b0; } + .editor-row textarea { + flex: 1; padding: 8px; border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 4px; font-size: 12px; font-family: monospace; + background: var(--card-background-color, #fff); + color: var(--primary-text-color, #212121); min-width: 0; resize: vertical; + } + .editor-row textarea:focus { outline: none; border-color: var(--primary-color, #03a9f4); } `; class MyMusicLibraryCardEditor extends HTMLElement { @@ -4640,6 +5017,7 @@ class MyMusicLibraryCardEditor extends HTMLElement { _tabDisplayLabel(tab) { if (tab.label) return tab.label; if (tab.name) return tab.name; + if (tab.type === "custom_element") return tab.element || this._t("editor.type_custom_element"); if (tab.type === "button") return tab.icon || "Button"; return this._t(`tabs.${tab.type}`) || tab.type; } @@ -4667,7 +5045,7 @@ class MyMusicLibraryCardEditor extends HTMLElement { ${this._showAddMenu ? `
- ${["player","search","library","settings","button"].map(type => ` + ${["player","search","library","settings","button","custom_element"].map(type => ` `).join("")}
` : ` @@ -4682,6 +5060,8 @@ class MyMusicLibraryCardEditor extends HTMLElement { _renderBasicFields(defaultTabOptions) { const cfg = this._config; + const navPos = cfg.nav_bar?.position || "top"; + const navAlign = cfg.nav_bar?.align || "start"; return `
@@ -4702,12 +5082,31 @@ class MyMusicLibraryCardEditor extends HTMLElement {
+
+
+
${this._t("editor.nav_bar_section")}
+
+ + +
+
+ + +
`; } _renderTabItem(tab, index, total) { const isExpanded = this._expandedTab === index; - const typeClass = tab.type === "button" ? " button" : ""; + const typeClass = tab.type === "button" ? " button" : tab.type === "custom_element" ? " custom_element" : ""; return `
@@ -4724,8 +5123,55 @@ class MyMusicLibraryCardEditor extends HTMLElement {
`; } + _renderActionSelect(tab, index) { + const actionType = tab.tap_action?.action || "none"; + const allActions = ["none","toggle","more-info","navigate","url","call-service","assist", + "mml_navigate_tab","mml_navigate_section","mml_control"]; + let actionFields = ""; + if (actionType === "navigate") { + actionFields = `
`; + } else if (actionType === "url") { + actionFields = `
`; + } else if (actionType === "call-service" || actionType === "perform-action") { + actionFields = `
`; + } else if (actionType === "mml_navigate_tab") { + const panelTabs = this._getResolvedTabs().filter(t => t.type && !["button","custom_element"].includes(t.type)); + actionFields = `
`; + } else if (actionType === "mml_navigate_section") { + const sections = ["artists","albums","playlists","tracks","radios","recently_played","recently_added","recommended","flows"]; + actionFields = `
`; + } else if (actionType === "mml_control") { + const cmds = ["play_pause","next","prev","shuffle","repeat","mute"]; + actionFields = `
`; + } + return ` +
+ + +
+ ${actionFields}`; + } + + _renderCustomElementBody(tab, index) { + return ` +
+
+ + +
+
+ + +
+ ${this._renderActionSelect(tab, index)} +
`; + } + _renderTabBody(tab, index) { if (tab.type === "button") return this._renderButtonBody(tab, index); + if (tab.type === "custom_element") return this._renderCustomElementBody(tab, index); let body = `
@@ -4797,27 +5243,6 @@ class MyMusicLibraryCardEditor extends HTMLElement { } _renderButtonBody(tab, index) { - const actionType = tab.tap_action?.action || "none"; - let actionFields = ""; - if (actionType === "navigate") { - actionFields = ` -
- - -
`; - } else if (actionType === "url") { - actionFields = ` -
- - -
`; - } else if (actionType === "call-service" || actionType === "perform-action") { - actionFields = ` -
- - -
`; - } return `
@@ -4832,15 +5257,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
-
- - -
- ${actionFields} + ${this._renderActionSelect(tab, index)}
`; } @@ -4871,6 +5288,17 @@ class MyMusicLibraryCardEditor extends HTMLElement { this._fireChanged(); }); + const _setNavBar = (key, val, defaultVal) => { + const navBar = { ...(this._config.nav_bar || {}) }; + if (val === defaultVal) delete navBar[key]; else navBar[key] = val; + this._config = { ...this._config }; + if (Object.keys(navBar).length) this._config.nav_bar = navBar; + else delete this._config.nav_bar; + this._fireChanged(); + }; + wrap.querySelector("#ed-nav-pos")?.addEventListener("change", (e) => _setNavBar("position", e.target.value, "top")); + wrap.querySelector("#ed-nav-align")?.addEventListener("change", (e) => _setNavBar("align", e.target.value, "start")); + // Toggle expand wrap.querySelectorAll("[data-toggle-idx]").forEach(el => { el.addEventListener("click", (e) => { @@ -4918,6 +5346,7 @@ class MyMusicLibraryCardEditor extends HTMLElement { const newTab = { type }; if (type === "library") newTab.sections = ["artists", "albums", "playlists", "tracks"]; if (type === "button") { newTab.icon = "mdi:gesture-tap"; newTab.tap_action = { action: "none" }; } + if (type === "custom_element") { newTab.element = ""; newTab.element_config = {}; newTab.tap_action = { action: "none" }; } t.push(newTab); this._showAddMenu = false; this._expandedTab = t.length - 1; @@ -5018,6 +5447,19 @@ class MyMusicLibraryCardEditor extends HTMLElement { this._updateTabs(t); }); }); + + // Custom element JSON config textarea + wrap.querySelectorAll("[data-ce-config]").forEach(ta => { + ta.addEventListener("change", () => { + const idx = parseInt(ta.dataset.idx); + try { + const cfg = _yamlLoad(ta.value.trim() || "{}"); + const t = [...tabs]; + t[idx] = { ...t[idx], element_config: cfg }; + this._updateTabs(t); + } catch(_) { /* invalid YAML — ignore, keep previous value */ } + }); + }); } _esc(str) { diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json index 0ab1ce18..dd81a6f0 100644 --- a/zigbee2mqtt/state.json +++ b/zigbee2mqtt/state.json @@ -4,7 +4,7 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 121.2, + "voltage": 121.7, "countdown_to_turn_on": 0, "ac_frequency": 60, "power_factor": 0.14, @@ -15,22 +15,22 @@ "latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota", "latest_release_notes": null }, - "power": 0.5, + "power": 0.4, "linkquality": 102, "current": 0.03, "power_on_behavior": "on" }, "0xffffb40e0607af27": { "state": "ON", - "voltage": 121, + "voltage": 121.1, "ac_frequency": 60, "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "power": 2.2, + "power": 1.9, "current": 0.1, "energy": 32.61, - "power_factor": 0.16, + "power_factor": 0.17, "update": { "state": "idle", "installed_version": 268513381, @@ -45,9 +45,9 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 120.8, + "voltage": 120.6, "countdown_to_turn_on": 0, - "energy": 65.52, + "energy": 65.54, "power_factor": 0.2, "ac_frequency": 60, "update": { @@ -58,7 +58,7 @@ "latest_release_notes": null }, "linkquality": 138, - "power": 45.4, + "power": 0.2, "current": 0.01, "power_on_behavior": "on" }, @@ -74,13 +74,13 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 119.3, + "voltage": 120.1, "state": "ON", "ac_frequency": 60, - "energy": 132.57, - "power": 95.9, - "current": 0.88, - "power_factor": 0.93, + "energy": 132.59, + "power": 101.8, + "current": 0.96, + "power_factor": 0.94, "update": { "state": "idle", "installed_version": 268513381, @@ -95,11 +95,11 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 121, - "energy": 63.35, + "voltage": 121.5, + "energy": 63.37, "state": "ON", - "power": 40.7, - "current": 0.47, + "power": 48.4, + "current": 0.62, "ac_frequency": 60, "power_factor": 0.69, "update": { @@ -114,12 +114,12 @@ }, "0xffffb40e060895b3": { "state": "ON", - "voltage": 120.3, + "voltage": 120.8, "ac_frequency": 60, "energy": 8.79, "current": 0.01, - "power": 0.1, - "power_factor": 0.1, + "power": 0, + "power_factor": 0.11, "linkquality": 109, "update": { "state": "idle", @@ -136,7 +136,7 @@ "0xffffb40e0608864e": { "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 121.3, + "voltage": 121.8, "energy": 20.09, "countdown_to_turn_on": 0, "state": "ON", @@ -172,10 +172,10 @@ "0xffffb40e060893d8": { "state": "ON", "led_brightness": 100, - "voltage": 121.3, + "voltage": 121.8, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "energy": 3.68, + "energy": 3.69, "power_on_behavior": "on", "linkquality": 142, "current": 0.09, @@ -187,12 +187,12 @@ "latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota", "latest_release_notes": null }, - "power_factor": 0.84, + "power_factor": 0.86, "power": 9.6 }, "0xa4c1380d0679ffff": { "battery": 100, - "temperature": 27.4, + "temperature": 27.3, "temperature_units": "celsius", "temperature_calibration": 0, "update": {