This commit is contained in:
Home Assistant Version Control
2026-08-24 22:07:33 +00:00
parent 8d3e232cd0
commit 6fa2672753
9 changed files with 643 additions and 86 deletions
@@ -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)
+127 -13
View File
@@ -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=<raw_image_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)
@@ -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"
}
@@ -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 `<div class="nav-btn nav-btn-custom" data-tab-btn="${tab.id}" data-ce-slot="${tab.id}"${sizeStyle}></div>`;
}
_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 {
</button>`;
}).join("");
const alignAttr = align !== "start" ? ` data-align="${align}"` : "";
const navStyle = this._config.nav_bar?.style ? ` style="${this._esc(this._config.nav_bar.style)}"` : "";
return `
<div class="nav-wrapper">
<div class="nav-fade-left"></div>
<div class="nav-fade-right"></div>
<nav class="nav">
<div class="nav-tabs">${items}</div>
<nav class="nav"${navStyle}>
<div class="nav-tabs"${alignAttr}>${items}</div>
</nav>
</div>`;
}
@@ -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 {
</div>
${this._showAddMenu ? `
<div class="add-tab-menu">
${["player","search","library","settings","button"].map(type => `
${["player","search","library","settings","button","custom_element"].map(type => `
<button data-add-type="${type}">${this._tabTypeLabel(type)}</button>
`).join("")}
</div>` : `
@@ -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 `
<div class="editor-section">
<div class="editor-row">
@@ -4702,12 +5082,31 @@ class MyMusicLibraryCardEditor extends HTMLElement {
<label>${this._t("editor.show_device_select")}</label>
<input id="ed-show-device" type="checkbox" ${cfg.show_device_select !== false ? "checked" : ""}>
</div>
</div>
<div class="editor-section">
<div class="editor-section-title">${this._t("editor.nav_bar_section")}</div>
<div class="editor-row">
<label>${this._t("editor.nav_bar_position")}</label>
<select id="ed-nav-pos">
${["top","bottom","left","right"].map(p =>
`<option value="${p}" ${navPos === p ? "selected" : ""}>${this._t(`editor.nav_bar_pos_${p}`)}</option>`
).join("")}
</select>
</div>
<div class="editor-row">
<label>${this._t("editor.nav_bar_align")}</label>
<select id="ed-nav-align">
${["start","center","end","space-between"].map(a =>
`<option value="${a}" ${navAlign === a ? "selected" : ""}>${this._t(`editor.nav_bar_align_${a.replace("-","_")}`)}</option>`
).join("")}
</select>
</div>
</div>`;
}
_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 `
<div class="tab-item" data-tab-idx="${index}">
<div class="tab-item-header" data-toggle-idx="${index}">
@@ -4724,8 +5123,55 @@ class MyMusicLibraryCardEditor extends HTMLElement {
</div>`;
}
_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 = `<div class="editor-row"><label>${this._t("editor.btn_nav_path")}</label><input data-btn-field="navigation_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.navigation_path || "")}"></div>`;
} else if (actionType === "url") {
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_url")}</label><input data-btn-field="url_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.url_path || "")}"></div>`;
} else if (actionType === "call-service" || actionType === "perform-action") {
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_service")}</label><input data-btn-field="perform_action" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.perform_action || tab.tap_action?.service || "")}"></div>`;
} else if (actionType === "mml_navigate_tab") {
const panelTabs = this._getResolvedTabs().filter(t => t.type && !["button","custom_element"].includes(t.type));
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_mml_tab")}</label><select data-btn-field="tab" data-idx="${index}">${panelTabs.map(t => `<option value="${t.type}" ${tab.tap_action?.tab === t.type ? "selected" : ""}>${this._t(`tabs.${t.type}`) || t.type}</option>`).join("")}</select></div>`;
} else if (actionType === "mml_navigate_section") {
const sections = ["artists","albums","playlists","tracks","radios","recently_played","recently_added","recommended","flows"];
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_mml_section")}</label><select data-btn-field="section" data-idx="${index}">${sections.map(s => `<option value="${s}" ${tab.tap_action?.section === s ? "selected" : ""}>${this._t(`lib.${s}`) || s}</option>`).join("")}</select></div>`;
} else if (actionType === "mml_control") {
const cmds = ["play_pause","next","prev","shuffle","repeat","mute"];
actionFields = `<div class="editor-row"><label>${this._t("editor.btn_mml_command")}</label><select data-btn-field="command" data-idx="${index}">${cmds.map(c => `<option value="${c}" ${tab.tap_action?.command === c ? "selected" : ""}>${this._t(`editor.mml_cmd_${c}`) || c}</option>`).join("")}</select></div>`;
}
return `
<div class="editor-row">
<label>${this._t("editor.btn_action_type")}</label>
<select data-btn-action-type data-idx="${index}">
${allActions.map(a => `<option value="${a}" ${actionType === a ? "selected" : ""}>${this._t(`editor.action_${a.replace(/-/g,"_")}`) || a}</option>`).join("")}
</select>
</div>
${actionFields}`;
}
_renderCustomElementBody(tab, index) {
return `
<div class="tab-item-body">
<div class="editor-row">
<label>${this._t("editor.btn_element_name")}</label>
<input data-field="element" data-idx="${index}" type="text" value="${this._esc(tab.element || "")}" placeholder="button-card">
</div>
<div class="editor-row" style="align-items:flex-start">
<label style="padding-top:6px">${this._t("editor.btn_element_config")}</label>
<textarea data-ce-config data-idx="${index}" rows="4">${this._esc(_yamlDump(tab.element_config || {}))}</textarea>
</div>
${this._renderActionSelect(tab, index)}
</div>`;
}
_renderTabBody(tab, index) {
if (tab.type === "button") return this._renderButtonBody(tab, index);
if (tab.type === "custom_element") return this._renderCustomElementBody(tab, index);
let body = `
<div class="tab-item-body">
<div class="editor-row">
@@ -4797,27 +5243,6 @@ class MyMusicLibraryCardEditor extends HTMLElement {
}
_renderButtonBody(tab, index) {
const actionType = tab.tap_action?.action || "none";
let actionFields = "";
if (actionType === "navigate") {
actionFields = `
<div class="editor-row">
<label>${this._t("editor.btn_nav_path")}</label>
<input data-btn-field="navigation_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.navigation_path || "")}">
</div>`;
} else if (actionType === "url") {
actionFields = `
<div class="editor-row">
<label>${this._t("editor.btn_url")}</label>
<input data-btn-field="url_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.url_path || "")}">
</div>`;
} else if (actionType === "call-service" || actionType === "perform-action") {
actionFields = `
<div class="editor-row">
<label>${this._t("editor.btn_service")}</label>
<input data-btn-field="perform_action" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.perform_action || tab.tap_action?.service || "")}">
</div>`;
}
return `
<div class="tab-item-body">
<div class="editor-row">
@@ -4832,15 +5257,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
<label>${this._t("editor.btn_entity")}</label>
<input data-field="entity" data-idx="${index}" type="text" value="${this._esc(tab.entity || "")}" placeholder="light.living_room">
</div>
<div class="editor-row">
<label>${this._t("editor.btn_action_type")}</label>
<select data-btn-action-type data-idx="${index}">
${["none","toggle","more-info","navigate","url","call-service","assist"].map(a =>
`<option value="${a}" ${actionType === a ? "selected" : ""}>${this._t(`editor.action_${a.replace("-","_").replace("-","_")}`) || a}</option>`
).join("")}
</select>
</div>
${actionFields}
${this._renderActionSelect(tab, index)}
</div>`;
}
@@ -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) {
+25 -25
View File
@@ -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": {