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.exceptions import ConfigEntryNotReady
from homeassistant.loader import async_get_integration 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 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__) _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(MusicAssistantRecommendationsView)
hass.http.register_view(MusicAssistantProvidersView) hass.http.register_view(MusicAssistantProvidersView)
hass.http.register_view(ImageProxyView) hass.http.register_view(ImageProxyView)
hass.http.register_view(MAThumbnailView)
# Register WebSocket command so the card can fetch its config # Register WebSocket command so the card can fetch its config
_register_websocket_commands(hass) _register_websocket_commands(hass)
+127 -13
View File
@@ -7,6 +7,7 @@ import enum
import logging import logging
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
from urllib.parse import quote
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
@@ -69,6 +70,18 @@ def _extract_thumbnail(item: dict) -> str:
return "" 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: def _serialize_search_results(results: Any) -> dict:
"""Convert a MA SearchResults object (or dict) to a plain dict.""" """Convert a MA SearchResults object (or dict) to a plain dict."""
if results is None: if results is None:
@@ -77,10 +90,9 @@ def _serialize_search_results(results: Any) -> dict:
if isinstance(safe, dict): if isinstance(safe, dict):
for key in ("tracks", "artists", "albums", "playlists", "radios"): for key in ("tracks", "artists", "albums", "playlists", "radios"):
for item in safe.get(key) or []: for item in safe.get(key) or []:
if isinstance(item, dict) and not item.get("thumbnail"): if isinstance(item, dict):
thumb = _extract_thumbnail(item) raw = item.get("thumbnail") or _extract_thumbnail(item)
if thumb: item["thumbnail"] = _make_thumb_url(raw)
item["thumbnail"] = thumb
return safe return safe
return {"tracks": [], "artists": [], "albums": [], "playlists": [], "raw": str(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): if isinstance(media_type, dict):
media_type = media_type.get("value", "") media_type = media_type.get("value", "")
thumbnail = _extract_thumbnail(item) thumbnail = _make_thumb_url(_extract_thumbnail(item))
artist = item.get("media_artist") or "" artist = item.get("media_artist") or ""
if not artist: if not artist:
@@ -554,7 +566,7 @@ def _normalize_browse_item(item: dict) -> dict:
if _path.startswith("folder/"): if _path.startswith("folder/"):
uri = f"{_scheme}://{_path[len('folder/'):]}" uri = f"{_scheme}://{_path[len('folder/'):]}"
thumbnail = _extract_thumbnail(item) thumbnail = _make_thumb_url(_extract_thumbnail(item))
artist = item.get("media_artist") or "" artist = item.get("media_artist") or ""
if not artist: if not artist:
@@ -680,6 +692,45 @@ def _parse_ma_uri(uri: str) -> tuple[str, str]:
return item_id, scheme 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( async def _get_subitems(
hass: HomeAssistant, hass: HomeAssistant,
action: str, action: str,
@@ -690,11 +741,11 @@ async def _get_subitems(
mass = _get_mass_client(hass) mass = _get_mass_client(hass)
if mass is None: if mass is None:
_LOGGER.warning("No Music Assistant (mass) client available for subitems") _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) music = getattr(mass, "music", None)
if not music: if not music:
return None return await _get_subitems_via_rest(hass, action, uri, limit)
methods = _SUBITEM_METHODS.get(action, []) methods = _SUBITEM_METHODS.get(action, [])
if not methods: 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) _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]] return [_normalize_library_item(_to_json_safe(i)) for i in items[:limit]]
except Exception as err: # noqa: BLE001 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 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 ───────────────────────────────────────────────────────────────── # ── Providers ─────────────────────────────────────────────────────────────────
@@ -853,9 +905,10 @@ def _normalize_queue_item(item: dict) -> dict:
queue_item_id = item.get("queue_item_id") or "" queue_item_id = item.get("queue_item_id") or ""
duration = item.get("duration") or 0 duration = item.get("duration") or 0
thumbnail = _extract_thumbnail(item) raw_thumb = _extract_thumbnail(item)
if not thumbnail: if not raw_thumb:
thumbnail = _extract_thumbnail(item.get("media_item") or {}) raw_thumb = _extract_thumbnail(item.get("media_item") or {})
thumbnail = _make_thumb_url(raw_thumb)
media_item = item.get("media_item") or {} media_item = item.get("media_item") or {}
uri = media_item.get("uri") or "" uri = media_item.get("uri") or ""
@@ -1361,3 +1414,64 @@ class ImageProxyView(HomeAssistantView):
) )
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return web.Response(status=HTTPStatus.BAD_GATEWAY) 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", "iot_class": "local_push",
"issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues", "issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues",
"requirements": [], "requirements": [],
"version": "3.10.4" "version": "3.12.4"
} }
@@ -5,7 +5,7 @@
* @version 1.0.0 * @version 1.0.0
*/ */
const CARD_VERSION = "3.10.4"; const CARD_VERSION = "3.12.4";
/* ─── Icons (inline SVG strings) ─────────────────────────── */ /* ─── Icons (inline SVG strings) ─────────────────────────── */
const ICONS = { const ICONS = {
@@ -169,6 +169,32 @@ const TRANSLATIONS = {
search_layout_rows: "Rows", search_layout_rows: "Rows",
search_layout_columns: "Columns", search_layout_columns: "Columns",
show_device_select: "Show device selection", 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: { fr: {
@@ -290,6 +316,32 @@ const TRANSLATIONS = {
search_layout_rows: "Lignes", search_layout_rows: "Lignes",
search_layout_columns: "Colonnes", search_layout_columns: "Colonnes",
show_device_select: "Afficher la sélection de l'appareil", 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: { de: {
@@ -411,10 +463,120 @@ const TRANSLATIONS = {
search_layout_rows: "Zeilen", search_layout_rows: "Zeilen",
search_layout_columns: "Spalten", search_layout_columns: "Spalten",
show_device_select: "Geräteauswahl anzeigen", 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 ─────────────────────────────────────────────────── */ /* ─── CSS ─────────────────────────────────────────────────── */
const STYLES = ` const STYLES = `
:host { :host {
@@ -545,6 +707,56 @@ const STYLES = `
.nav-btn ha-icon { --mdc-icon-size: 20px; display: block; pointer-events: none; } .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 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; } .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 ── */ /* ── CONTENT AREA ── */
/* position:relative + inset:0 on children is the most reliable way to /* 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 give tab panels a definite pixel height without relying on flex cross-axis
@@ -1146,7 +1358,7 @@ const STYLES = `
/* /*
LIBRARY TAB 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 { .lib-filters {
display: flex; display: flex;
@@ -1197,7 +1409,7 @@ const STYLES = `
.lib-filter-fav.active { background: var(--accent); color: #000; border-color: var(--accent); } .lib-filter-fav.active { background: var(--accent); color: #000; border-color: var(--accent); }
.lib-filter-fav svg { width: 14px; height: 14px; fill: currentColor; } .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 { margin-bottom: 8px; }
.lib-section-header { .lib-section-header {
@@ -1219,6 +1431,7 @@ const STYLES = `
scrollbar-width: none; scrollbar-width: none;
position: relative; position: relative;
} }
.lib-scroll.scroll-locked { overflow-x: hidden !important; }
.lib-scroll::-webkit-scrollbar { display: none; } .lib-scroll::-webkit-scrollbar { display: none; }
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
.lib-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.2) transparent; } .lib-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.2) transparent; }
@@ -1815,6 +2028,12 @@ class MyMusicLibraryCard extends HTMLElement {
const typeCounts = {}; const typeCounts = {};
return config.tabs.map(t => { return config.tabs.map(t => {
const type = t.type || "button"; 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") { if (type === "button") {
return { type: "button", id: `btn-${idx++}`, icon: t.icon, name: t.name || "", entity: t.entity, 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, 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; typeCounts[type] = (typeCounts[type] || 0) + 1;
const id = type === "settings" ? "settings" : (typeCounts[type] > 1 ? `${type}-${typeCounts[type] - 1}` : type); 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, 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") { if (type === "library") {
const sections = Array.isArray(t.sections) ? t.sections.filter(s => VALID_SECTIONS.includes(s)) : null; const sections = Array.isArray(t.sections) ? t.sections.filter(s => VALID_SECTIONS.includes(s)) : null;
tab.sections = sections && sections.length ? sections : DEFAULT_SECTIONS; tab.sections = sections && sections.length ? sections : DEFAULT_SECTIONS;
@@ -2068,8 +2287,10 @@ class MyMusicLibraryCard extends HTMLElement {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = `card-root${this._isMobile ? " mml-mobile" : ""}`; 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 = { const panelRenderers = {
player: (t) => this._renderPlayerTab(), player: (t) => this._renderPlayerTab(),
search: (t) => this._renderSearchTab(), search: (t) => this._renderSearchTab(),
@@ -2107,8 +2328,20 @@ class MyMusicLibraryCard extends HTMLElement {
this._updatePlayerContent(card); 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() { _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") { if (t.type === "button") {
return this._renderNavButton(t); return this._renderNavButton(t);
} }
@@ -2131,12 +2364,14 @@ class MyMusicLibraryCard extends HTMLElement {
</button>`; </button>`;
}).join(""); }).join("");
const alignAttr = align !== "start" ? ` data-align="${align}"` : "";
const navStyle = this._config.nav_bar?.style ? ` style="${this._esc(this._config.nav_bar.style)}"` : "";
return ` return `
<div class="nav-wrapper"> <div class="nav-wrapper">
<div class="nav-fade-left"></div> <div class="nav-fade-left"></div>
<div class="nav-fade-right"></div> <div class="nav-fade-right"></div>
<nav class="nav"> <nav class="nav"${navStyle}>
<div class="nav-tabs">${items}</div> <div class="nav-tabs"${alignAttr}>${items}</div>
</nav> </nav>
</div>`; </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 ── */ /* ── Nav button action handler ── */
@@ -2577,6 +2833,75 @@ class MyMusicLibraryCard extends HTMLElement {
})); }));
break; 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: default:
break; break;
} }
@@ -2601,12 +2926,17 @@ class MyMusicLibraryCard extends HTMLElement {
_updateNavButtons(card) { _updateNavButtons(card) {
for (const tab of this._resolvedTabs) { for (const tab of this._resolvedTabs) {
if (tab.type !== "button" || !tab.entity) continue; if (tab.type === "button" && tab.entity) {
const st = this._hass?.states[tab.entity]; const st = this._hass?.states[tab.entity];
const isActive = st ? ["on", "playing", "active", "home"].includes(st.state) : false; const isActive = st ? ["on", "playing", "active", "home"].includes(st.state) : false;
const el = card.querySelector(`[data-tab-btn="${tab.id}"]`); const el = card.querySelector(`[data-tab-btn="${tab.id}"]`);
if (el) el.classList.toggle("active", isActive); 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) { _resolveImageUrl(url) {
@@ -3183,6 +3513,12 @@ class MyMusicLibraryCard extends HTMLElement {
return null; 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. /* Parse the result of music_assistant/search WebSocket command.
MA returns either a flat {tracks,artists,albums,playlists} or MA returns either a flat {tracks,artists,albums,playlists} or
a nested {results: {tracks,...}}. */ a nested {results: {tracks,...}}. */
@@ -3193,12 +3529,13 @@ class MyMusicLibraryCard extends HTMLElement {
for (const [key, type] of Object.entries(map)) { for (const [key, type] of Object.entries(map)) {
const items = root[key] || []; const items = root[key] || [];
for (const item of items) { 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({ out[key].push({
id: item.uri || item.item_id || "", id: item.uri || item.item_id || "",
type, type,
title: item.name || item.title || "", title: item.name || item.title || "",
subtitle: item.artists?.[0]?.name || item.artist?.name || item.owner_name || "", 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, can_play: true,
}); });
} }
@@ -3831,6 +4168,38 @@ class MyMusicLibraryCard extends HTMLElement {
} }
this._attachLibInfiniteScroll(libEl); 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) { _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); } .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 { transition: transform 0.2s; font-size: 12px; }
.expand-chevron.open { transform: rotate(90deg); } .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 { class MyMusicLibraryCardEditor extends HTMLElement {
@@ -4640,6 +5017,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
_tabDisplayLabel(tab) { _tabDisplayLabel(tab) {
if (tab.label) return tab.label; if (tab.label) return tab.label;
if (tab.name) return tab.name; 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"; if (tab.type === "button") return tab.icon || "Button";
return this._t(`tabs.${tab.type}`) || tab.type; return this._t(`tabs.${tab.type}`) || tab.type;
} }
@@ -4667,7 +5045,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
</div> </div>
${this._showAddMenu ? ` ${this._showAddMenu ? `
<div class="add-tab-menu"> <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> <button data-add-type="${type}">${this._tabTypeLabel(type)}</button>
`).join("")} `).join("")}
</div>` : ` </div>` : `
@@ -4682,6 +5060,8 @@ class MyMusicLibraryCardEditor extends HTMLElement {
_renderBasicFields(defaultTabOptions) { _renderBasicFields(defaultTabOptions) {
const cfg = this._config; const cfg = this._config;
const navPos = cfg.nav_bar?.position || "top";
const navAlign = cfg.nav_bar?.align || "start";
return ` return `
<div class="editor-section"> <div class="editor-section">
<div class="editor-row"> <div class="editor-row">
@@ -4702,12 +5082,31 @@ class MyMusicLibraryCardEditor extends HTMLElement {
<label>${this._t("editor.show_device_select")}</label> <label>${this._t("editor.show_device_select")}</label>
<input id="ed-show-device" type="checkbox" ${cfg.show_device_select !== false ? "checked" : ""}> <input id="ed-show-device" type="checkbox" ${cfg.show_device_select !== false ? "checked" : ""}>
</div> </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>`; </div>`;
} }
_renderTabItem(tab, index, total) { _renderTabItem(tab, index, total) {
const isExpanded = this._expandedTab === index; const isExpanded = this._expandedTab === index;
const typeClass = tab.type === "button" ? " button" : ""; const typeClass = tab.type === "button" ? " button" : tab.type === "custom_element" ? " custom_element" : "";
return ` return `
<div class="tab-item" data-tab-idx="${index}"> <div class="tab-item" data-tab-idx="${index}">
<div class="tab-item-header" data-toggle-idx="${index}"> <div class="tab-item-header" data-toggle-idx="${index}">
@@ -4724,8 +5123,55 @@ class MyMusicLibraryCardEditor extends HTMLElement {
</div>`; </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) { _renderTabBody(tab, index) {
if (tab.type === "button") return this._renderButtonBody(tab, index); if (tab.type === "button") return this._renderButtonBody(tab, index);
if (tab.type === "custom_element") return this._renderCustomElementBody(tab, index);
let body = ` let body = `
<div class="tab-item-body"> <div class="tab-item-body">
<div class="editor-row"> <div class="editor-row">
@@ -4797,27 +5243,6 @@ class MyMusicLibraryCardEditor extends HTMLElement {
} }
_renderButtonBody(tab, index) { _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 ` return `
<div class="tab-item-body"> <div class="tab-item-body">
<div class="editor-row"> <div class="editor-row">
@@ -4832,15 +5257,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
<label>${this._t("editor.btn_entity")}</label> <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"> <input data-field="entity" data-idx="${index}" type="text" value="${this._esc(tab.entity || "")}" placeholder="light.living_room">
</div> </div>
<div class="editor-row"> ${this._renderActionSelect(tab, index)}
<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}
</div>`; </div>`;
} }
@@ -4871,6 +5288,17 @@ class MyMusicLibraryCardEditor extends HTMLElement {
this._fireChanged(); 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 // Toggle expand
wrap.querySelectorAll("[data-toggle-idx]").forEach(el => { wrap.querySelectorAll("[data-toggle-idx]").forEach(el => {
el.addEventListener("click", (e) => { el.addEventListener("click", (e) => {
@@ -4918,6 +5346,7 @@ class MyMusicLibraryCardEditor extends HTMLElement {
const newTab = { type }; const newTab = { type };
if (type === "library") newTab.sections = ["artists", "albums", "playlists", "tracks"]; if (type === "library") newTab.sections = ["artists", "albums", "playlists", "tracks"];
if (type === "button") { newTab.icon = "mdi:gesture-tap"; newTab.tap_action = { action: "none" }; } 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); t.push(newTab);
this._showAddMenu = false; this._showAddMenu = false;
this._expandedTab = t.length - 1; this._expandedTab = t.length - 1;
@@ -5018,6 +5447,19 @@ class MyMusicLibraryCardEditor extends HTMLElement {
this._updateTabs(t); 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) { _esc(str) {
+25 -25
View File
@@ -4,7 +4,7 @@
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 121.2, "voltage": 121.7,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"ac_frequency": 60, "ac_frequency": 60,
"power_factor": 0.14, "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_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null "latest_release_notes": null
}, },
"power": 0.5, "power": 0.4,
"linkquality": 102, "linkquality": 102,
"current": 0.03, "current": 0.03,
"power_on_behavior": "on" "power_on_behavior": "on"
}, },
"0xffffb40e0607af27": { "0xffffb40e0607af27": {
"state": "ON", "state": "ON",
"voltage": 121, "voltage": 121.1,
"ac_frequency": 60, "ac_frequency": 60,
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"power": 2.2, "power": 1.9,
"current": 0.1, "current": 0.1,
"energy": 32.61, "energy": 32.61,
"power_factor": 0.16, "power_factor": 0.17,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -45,9 +45,9 @@
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 120.8, "voltage": 120.6,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"energy": 65.52, "energy": 65.54,
"power_factor": 0.2, "power_factor": 0.2,
"ac_frequency": 60, "ac_frequency": 60,
"update": { "update": {
@@ -58,7 +58,7 @@
"latest_release_notes": null "latest_release_notes": null
}, },
"linkquality": 138, "linkquality": 138,
"power": 45.4, "power": 0.2,
"current": 0.01, "current": 0.01,
"power_on_behavior": "on" "power_on_behavior": "on"
}, },
@@ -74,13 +74,13 @@
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"voltage": 119.3, "voltage": 120.1,
"state": "ON", "state": "ON",
"ac_frequency": 60, "ac_frequency": 60,
"energy": 132.57, "energy": 132.59,
"power": 95.9, "power": 101.8,
"current": 0.88, "current": 0.96,
"power_factor": 0.93, "power_factor": 0.94,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -95,11 +95,11 @@
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"voltage": 121, "voltage": 121.5,
"energy": 63.35, "energy": 63.37,
"state": "ON", "state": "ON",
"power": 40.7, "power": 48.4,
"current": 0.47, "current": 0.62,
"ac_frequency": 60, "ac_frequency": 60,
"power_factor": 0.69, "power_factor": 0.69,
"update": { "update": {
@@ -114,12 +114,12 @@
}, },
"0xffffb40e060895b3": { "0xffffb40e060895b3": {
"state": "ON", "state": "ON",
"voltage": 120.3, "voltage": 120.8,
"ac_frequency": 60, "ac_frequency": 60,
"energy": 8.79, "energy": 8.79,
"current": 0.01, "current": 0.01,
"power": 0.1, "power": 0,
"power_factor": 0.1, "power_factor": 0.11,
"linkquality": 109, "linkquality": 109,
"update": { "update": {
"state": "idle", "state": "idle",
@@ -136,7 +136,7 @@
"0xffffb40e0608864e": { "0xffffb40e0608864e": {
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 121.3, "voltage": 121.8,
"energy": 20.09, "energy": 20.09,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"state": "ON", "state": "ON",
@@ -172,10 +172,10 @@
"0xffffb40e060893d8": { "0xffffb40e060893d8": {
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"voltage": 121.3, "voltage": 121.8,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"energy": 3.68, "energy": 3.69,
"power_on_behavior": "on", "power_on_behavior": "on",
"linkquality": 142, "linkquality": 142,
"current": 0.09, "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_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null "latest_release_notes": null
}, },
"power_factor": 0.84, "power_factor": 0.86,
"power": 9.6 "power": 9.6
}, },
"0xa4c1380d0679ffff": { "0xa4c1380d0679ffff": {
"battery": 100, "battery": 100,
"temperature": 27.4, "temperature": 27.3,
"temperature_units": "celsius", "temperature_units": "celsius",
"temperature_calibration": 0, "temperature_calibration": 0,
"update": { "update": {