/**
* My Music Library Card
* A responsive music player card for Home Assistant + Music Assistant.
* No external dependencies — pure vanilla JS Custom Element.
* @version 1.0.0
*/
const CARD_VERSION = "3.10.4";
/* ─── Icons (inline SVG strings) ─────────────────────────── */
const ICONS = {
play: ` `,
pause: ` `,
stop: ` `,
prev: ` `,
next: ` `,
shuffle: ` `,
repeat: ` `,
repeatOne: ` `,
volumeHigh: ` `,
volumeMute: ` `,
search: ` `,
library: ` `,
player: ` `,
heart: ` `,
heartOutline: ` `,
device: ` `,
close: ` `,
settings: ` `,
music: ` `,
album: ` `,
artist: ` `,
playlist: ` `,
chevronLeft: ` `,
chevronRight: ` `,
plus: ` `,
group: ` `,
folder: ` `,
folderOpen: ` `,
home: ` `,
radio: ` `,
queue: ` `,
remove: ` `,
addNext: ` `,
history: ` `,
sparkle: ` `,
newBox: ` `,
wave: ` `,
};
/* ─── i18n ────────────────────────────────────────────────── */
const TRANSLATIONS = {
en: {
tabs: { player: "Player", search: "Search", library: "Library", settings: "Settings" },
player: {
nothing_playing: "Nothing playing",
select_player: "Select a player",
no_player: "No player found",
},
btns: {
shuffle: "Shuffle", prev: "Previous", play_pause: "Play/Pause",
next: "Next", repeat: "Repeat", mute: "Mute", play: "Play",
},
search: {
placeholder: "Artists, albums, tracks…",
type_hint: "Type to search your music library",
searching: "Searching…",
unavailable: "Search unavailable",
no_results: "No results for",
player_label: "Player",
console_hint: "Check browser console (F12) for details.",
artists: "Artists", albums: "Albums", tracks: "Tracks", playlists: "Playlists", radios: "Radios",
},
lib: {
loading: "Loading library…",
loading_short: "Loading…",
artists: "Artists",
albums: "Albums",
playlists: "Playlists",
tracks: "Tracks",
radios: "Radios",
recently_played: "Recently played",
recently_added: "Recently added",
recommended: "Recommended",
flows: "Flows",
filter_all: "All",
filter_local: "Local",
filter_streaming: "Streaming",
filter_favorites: "Favorites",
empty: "Library is empty or Music Assistant is not connected.",
empty_hint: "Make sure Music Assistant integration is installed and running.",
no_albums: "No albums found",
load_error: "Could not load albums",
album_types: { album: "Albums", ep: "EPs", single: "Singles", compilation: "Compilations" },
mode_catalogue: "Catalogue",
mode_browse: "Browse",
browse_root: "Root",
browse_play: "Play",
browse_error: "Could not load folder contents",
browse_empty: "Empty folder",
},
queue: { up_next: "Up Next", empty: "Queue is empty", play_next: "Play next", add_to_end: "Add to end", added_next: "Added after current track", added_end: "Added to end of queue", remove: "Remove", toggle: "Toggle queue", start_mix: "Start a mix", mix_started: "Mix started" },
errors: { media_not_found: "Media not found on source" },
nav: { back: "← Back" },
group: {
title: "Choose a device",
section_master: "Active",
section_members: "Group members",
section_available: "Available",
attach: "Add to group",
detach: "Remove from group",
no_players: "No Music Assistant players found.",
volume: "Volume",
},
settings: {
title: "Settings",
providers_title: "Library providers",
providers_hint: "Choose which providers appear in your library",
providers_empty: "No providers found — check Music Assistant connection",
debug_active: "Debug mode is active",
debug_hint: "Detailed logs are visible in the browser console (F12) and in HA logs (filter: my_music_library). Disable in integration options when done.",
layout_title: "Library layout",
layout_hint: "How sections are displayed",
layout_lanes: "Lanes",
layout_grid: "Grid",
layout_columns: "Columns",
layout_auto: "Auto",
},
editor: {
default_tab: "Default tab",
entity: "Entity (media_player)",
entity_hint: "e.g. media_player.living_room",
height: "Height",
height_hint: "Auto (fill container)",
tabs_title: "Tabs",
add_tab: "Add tab",
tab_label: "Label",
tab_label_hint: "Custom label (empty = default)",
tab_icon: "Icon",
tab_icon_hint: "e.g. mdi:play-circle",
sections_title: "Library sections",
layout_label: "Layout",
layout_grid_disabled: "Grid is only available with a single section",
btn_icon: "Icon",
btn_name: "Name",
btn_entity: "Entity",
btn_action: "Tap action",
btn_action_type: "Action type",
btn_nav_path: "Navigation path",
btn_url: "URL",
btn_service: "Service",
action_none: "None",
action_toggle: "Toggle",
action_more_info: "More info",
action_navigate: "Navigate",
action_url: "Open URL",
action_call_service: "Call service",
action_assist: "Assist",
type_player: "Player",
type_search: "Search",
type_library: "Library",
type_settings: "Settings",
type_button: "Button",
confirm_delete: "Remove this tab?",
move_up: "Move up",
move_down: "Move down",
delete: "Delete",
search_layout: "Layout",
search_layout_rows: "Rows",
search_layout_columns: "Columns",
show_device_select: "Show device selection",
},
},
fr: {
tabs: { player: "Lecteur", search: "Recherche", library: "Bibliothèque", settings: "Paramètres" },
player: {
nothing_playing: "Rien en cours de lecture",
select_player: "Sélectionnez un lecteur",
no_player: "Aucun lecteur trouvé",
},
btns: {
shuffle: "Aléatoire", prev: "Précédent", play_pause: "Lecture / Pause",
next: "Suivant", repeat: "Répéter", mute: "Muet", play: "Lecture",
},
search: {
placeholder: "Artistes, albums, titres…",
type_hint: "Tapez pour rechercher dans votre bibliothèque musicale",
searching: "Recherche en cours…",
unavailable: "Recherche indisponible",
no_results: "Aucun résultat pour",
player_label: "Lecteur",
console_hint: "Consultez la console du navigateur (F12) pour plus de détails.",
artists: "Artistes", albums: "Albums", tracks: "Titres", playlists: "Playlists", radios: "Radios",
},
lib: {
loading: "Chargement de la bibliothèque…",
loading_short: "Chargement…",
artists: "Artistes",
albums: "Albums",
playlists: "Playlists",
tracks: "Titres",
radios: "Radios",
recently_played: "Écoutés récemment",
recently_added: "Ajoutés récemment",
recommended: "Recommandations",
flows: "Flows",
filter_all: "Tout",
filter_local: "Local",
filter_streaming: "Streaming",
filter_favorites: "Favoris",
empty: "La bibliothèque est vide ou Music Assistant n'est pas connecté.",
empty_hint: "Assurez-vous que l'intégration Music Assistant est installée et en cours d'exécution.",
no_albums: "Aucun album trouvé",
load_error: "Impossible de charger les albums",
album_types: { album: "Albums", ep: "EPs", single: "Singles", compilation: "Compilations" },
mode_catalogue: "Catalogue",
mode_browse: "Parcourir",
browse_root: "Racine",
browse_play: "Lire",
browse_error: "Impossible de charger le contenu du dossier",
browse_empty: "Dossier vide",
},
queue: { up_next: "À suivre", empty: "File d'attente vide", play_next: "Lire après le titre en cours", add_to_end: "Ajouter à la fin", added_next: "Ajouté après le titre en cours", added_end: "Ajouté à la fin de la file d'attente", remove: "Supprimer", toggle: "Afficher/masquer la file", start_mix: "Lancer un mix", mix_started: "Mix lancé" },
errors: { media_not_found: "Média introuvable sur la source" },
nav: { back: "← Retour" },
group: {
title: "Choisir un appareil",
section_master: "Actif",
section_members: "Membres du groupe",
section_available: "Disponible",
attach: "Ajouter au groupe",
detach: "Retirer du groupe",
no_players: "Aucun lecteur Music Assistant trouvé.",
volume: "Volume",
},
settings: {
title: "Paramètres",
providers_title: "Sources de la bibliothèque",
providers_hint: "Choisissez quelles sources apparaissent dans votre bibliothèque",
providers_empty: "Aucune source trouvée — vérifiez la connexion à Music Assistant",
debug_active: "Mode débogage actif",
debug_hint: "Les logs détaillés sont visibles dans la console du navigateur (F12) et dans les journaux HA (filtre : my_music_library). Désactivez dans les options de l'intégration une fois terminé.",
layout_title: "Disposition de la bibliothèque",
layout_hint: "Mode d'affichage des sections",
layout_lanes: "Lignes",
layout_grid: "Grille",
layout_columns: "Colonnes",
layout_auto: "Auto",
},
editor: {
default_tab: "Onglet par défaut",
entity: "Entité (media_player)",
entity_hint: "ex. media_player.salon",
height: "Hauteur",
height_hint: "Auto (remplit le conteneur)",
tabs_title: "Onglets",
add_tab: "Ajouter un onglet",
tab_label: "Libellé",
tab_label_hint: "Libellé personnalisé (vide = défaut)",
tab_icon: "Icône",
tab_icon_hint: "ex. mdi:play-circle",
sections_title: "Sections de la bibliothèque",
layout_label: "Disposition",
layout_grid_disabled: "La grille n'est disponible qu'avec une seule section",
btn_icon: "Icône",
btn_name: "Nom",
btn_entity: "Entité",
btn_action: "Action au toucher",
btn_action_type: "Type d'action",
btn_nav_path: "Chemin de navigation",
btn_url: "URL",
btn_service: "Service",
action_none: "Aucune",
action_toggle: "Basculer",
action_more_info: "Plus d'infos",
action_navigate: "Naviguer",
action_url: "Ouvrir URL",
action_call_service: "Appeler un service",
action_assist: "Assistant",
type_player: "Lecteur",
type_search: "Recherche",
type_library: "Bibliothèque",
type_settings: "Paramètres",
type_button: "Bouton",
confirm_delete: "Supprimer cet onglet ?",
move_up: "Monter",
move_down: "Descendre",
delete: "Supprimer",
search_layout: "Disposition",
search_layout_rows: "Lignes",
search_layout_columns: "Colonnes",
show_device_select: "Afficher la sélection de l'appareil",
},
},
de: {
tabs: { player: "Wiedergabe", search: "Suche", library: "Bibliothek", settings: "Einstellungen" },
player: {
nothing_playing: "Nichts wird abgespielt",
select_player: "Player auswählen",
no_player: "Kein Player gefunden",
},
btns: {
shuffle: "Zufällig", prev: "Zurück", play_pause: "Wiedergabe / Pause",
next: "Weiter", repeat: "Wiederholen", mute: "Stummschalten", play: "Abspielen",
},
search: {
placeholder: "Künstler, Alben, Titel…",
type_hint: "Eingabe um die Musikbibliothek zu durchsuchen",
searching: "Suche läuft…",
unavailable: "Suche nicht verfügbar",
no_results: "Keine Ergebnisse für",
player_label: "Player",
console_hint: "Browser-Konsole (F12) für Details prüfen.",
artists: "Künstler", albums: "Alben", tracks: "Titel", playlists: "Playlists", radios: "Radios",
},
lib: {
loading: "Bibliothek wird geladen…",
loading_short: "Laden…",
artists: "Künstler",
albums: "Alben",
playlists: "Playlists",
tracks: "Titel",
radios: "Radios",
recently_played: "Kürzlich gespielt",
recently_added: "Kürzlich hinzugefügt",
recommended: "Empfehlungen",
flows: "Flows",
filter_all: "Alle",
filter_local: "Lokal",
filter_streaming: "Streaming",
filter_favorites: "Favoriten",
empty: "Bibliothek ist leer oder Music Assistant ist nicht verbunden.",
empty_hint: "Stellen Sie sicher, dass die Music Assistant Integration installiert und aktiv ist.",
no_albums: "Keine Alben gefunden",
load_error: "Alben konnten nicht geladen werden",
album_types: { album: "Alben", ep: "EPs", single: "Singles", compilation: "Kompilationen" },
mode_catalogue: "Katalog",
mode_browse: "Durchsuchen",
browse_root: "Wurzel",
browse_play: "Abspielen",
browse_error: "Ordnerinhalt konnte nicht geladen werden",
browse_empty: "Leerer Ordner",
},
queue: { up_next: "Als Nächstes", empty: "Warteschlange ist leer", play_next: "Als Nächstes abspielen", add_to_end: "Am Ende hinzufügen", added_next: "Nach dem aktuellen Titel hinzugefügt", added_end: "Am Ende der Warteschlange hinzugefügt", remove: "Entfernen", toggle: "Warteschlange ein-/ausblenden", start_mix: "Mix starten", mix_started: "Mix gestartet" },
errors: { media_not_found: "Medium auf der Quelle nicht gefunden" },
nav: { back: "← Zurück" },
group: {
title: "Gerät auswählen",
section_master: "Aktiv",
section_members: "Gruppenmitglieder",
section_available: "Verfügbar",
attach: "Zur Gruppe hinzufügen",
detach: "Aus der Gruppe entfernen",
no_players: "Keine Music Assistant Player gefunden.",
volume: "Lautstärke",
},
settings: {
title: "Einstellungen",
providers_title: "Bibliotheksquellen",
providers_hint: "Wählen Sie, welche Quellen in Ihrer Bibliothek angezeigt werden",
providers_empty: "Keine Quellen gefunden — Music Assistant-Verbindung prüfen",
debug_active: "Debug-Modus ist aktiv",
debug_hint: "Detaillierte Protokolle sind in der Browser-Konsole (F12) und in den HA-Logs (Filter: my_music_library) sichtbar. Nach dem Debugging in den Integrationsoptionen deaktivieren.",
layout_title: "Bibliothek-Layout",
layout_hint: "Anzeigemodus der Bereiche",
layout_lanes: "Bahnen",
layout_grid: "Raster",
layout_columns: "Spalten",
layout_auto: "Auto",
},
editor: {
default_tab: "Standard-Tab",
entity: "Entität (media_player)",
entity_hint: "z.B. media_player.wohnzimmer",
height: "Höhe",
height_hint: "Auto (Container füllen)",
tabs_title: "Tabs",
add_tab: "Tab hinzufügen",
tab_label: "Bezeichnung",
tab_label_hint: "Eigene Bezeichnung (leer = Standard)",
tab_icon: "Symbol",
tab_icon_hint: "z.B. mdi:play-circle",
sections_title: "Bibliotheksbereiche",
layout_label: "Layout",
layout_grid_disabled: "Raster ist nur mit einem einzelnen Bereich verfügbar",
btn_icon: "Symbol",
btn_name: "Name",
btn_entity: "Entität",
btn_action: "Tipp-Aktion",
btn_action_type: "Aktionstyp",
btn_nav_path: "Navigationspfad",
btn_url: "URL",
btn_service: "Dienst",
action_none: "Keine",
action_toggle: "Umschalten",
action_more_info: "Mehr Infos",
action_navigate: "Navigieren",
action_url: "URL öffnen",
action_call_service: "Dienst aufrufen",
action_assist: "Assistent",
type_player: "Wiedergabe",
type_search: "Suche",
type_library: "Bibliothek",
type_settings: "Einstellungen",
type_button: "Schaltfläche",
confirm_delete: "Diesen Tab entfernen?",
move_up: "Nach oben",
move_down: "Nach unten",
delete: "Löschen",
search_layout: "Layout",
search_layout_rows: "Zeilen",
search_layout_columns: "Spalten",
show_device_select: "Geräteauswahl anzeigen",
},
},
};
/* ─── CSS ─────────────────────────────────────────────────── */
const STYLES = `
:host {
display: block;
height: 100%;
min-height: var(--mml-height, 400px);
font-family: var(--paper-font-body1_-_font-family, sans-serif);
--accent: var(--primary-color, #1db954);
--bg: var(--ha-card-background, var(--card-background-color, #1e1e2e));
--bg2: color-mix(in srgb, var(--bg) 80%, white 20%);
--text: var(--primary-text-color, #fff);
--text2: var(--secondary-text-color, rgba(255,255,255,0.78));
--border: color-mix(in srgb, var(--text) 12%, transparent);
--radius: 12px;
--control-size: 52px;
color: var(--text);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
.card-root {
background: var(--bg);
border-radius: 0 0 var(--radius) var(--radius);
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* ── NAV TABS ── */
.nav-wrapper {
position: relative;
flex-shrink: 0;
}
.nav {
display: flex;
background: var(--bg2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
scroll-snap-type: x mandatory;
}
.nav::-webkit-scrollbar { display: none; }
.nav-fade-left,
.nav-fade-right {
position: absolute;
top: 0;
bottom: 1px;
width: 24px;
pointer-events: none;
opacity: 0;
transition: opacity .2s;
z-index: 2;
}
.nav-fade-left {
left: 0;
background: linear-gradient(to right, var(--bg2), transparent);
}
.nav-fade-right {
right: 0;
background: linear-gradient(to left, var(--bg2), transparent);
}
.nav-fade-left.visible,
.nav-fade-right.visible { opacity: 1; }
.nav-tab {
flex: 1 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 8px;
cursor: pointer;
border: none;
background: none;
color: var(--text2);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
scroll-snap-align: start;
transition: color .2s, background .2s;
-webkit-tap-highlight-color: transparent;
}
.nav-tab svg { width: 18px; height: 18px; fill: currentColor; flex-shrink: 0; }
.nav-tab ha-icon { --mdc-icon-size: 18px; display: block; pointer-events: none; flex-shrink: 0; }
.nav-tab { border-right: 2px solid var(--border); }
.nav-tab.active {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-bottom: 2px solid var(--accent);
}
.nav-tab:not(.active):hover { color: var(--text); background: rgba(255,255,255,0.04); }
/* ── NAV TABS WRAPPER (allows extra buttons on sides) ── */
.nav-tabs { display: flex; flex: 1 0 auto; align-items: stretch; }
.nav-tab { align-self: stretch; }
/* ── NAV ACTION BUTTONS ── */
.nav-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 8px;
min-width: 36px;
min-height: 44px;
box-sizing: border-box;
cursor: pointer;
border: none;
border-radius: 0;
background: none;
color: var(--text2);
white-space: nowrap;
flex-shrink: 0;
scroll-snap-align: start;
transition: color .2s, background .2s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
}
.nav-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.nav-btn:active { background: rgba(255,255,255,0.12); }
.nav-btn.active { color: var(--accent); }
.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; }
/* ── 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
height inheritance, which breaks in certain browser/HA layout combinations */
.content { flex: 1; min-height: 0; position: relative; overflow: hidden; }
.tab-panel { display: none; position: absolute; inset: 0; flex-direction: column; overflow: hidden; }
.tab-panel.active { display: flex; }
/* ══════════════════════════════════════════
PLAYER TAB
══════════════════════════════════════════ */
/* Wrapper that holds player-panel + queue side by side (or stacked on mobile) */
.player-tab-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column; /* mobile: stacked */
overflow: hidden;
}
.player-panel {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Art section — fills all available space above controls */
.player-art-section {
flex: 1;
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
padding: 16px 16px 8px;
overflow: hidden;
}
/* Controls section — pinned to bottom, never scrolls away */
.player-controls-section {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 16px 16px;
}
/* Album art */
.art-wrapper {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
max-width: 100%;
}
.art {
height: 100%;
width: auto;
aspect-ratio: 1;
max-width: 100%;
border-radius: 12px;
object-fit: cover;
background: var(--bg2);
box-shadow: 0 8px 40px rgba(0,0,0,.5);
}
.art-placeholder {
height: 100%;
width: auto;
aspect-ratio: 1;
max-width: 100%;
border-radius: 12px;
background: var(--bg2);
display: flex;
align-items: center;
justify-content: center;
}
.art-placeholder svg { width: 72px; height: 72px; fill: var(--text2); opacity: .5; }
/* Track info */
.track-info { text-align: center; }
.track-title {
font-size: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
font-size: 14px;
color: var(--text2);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Progress */
.progress-wrapper { display: flex; flex-direction: column; gap: 6px; }
.progress-bar-container {
position: relative;
height: 6px;
background: var(--border);
border-radius: 3px;
cursor: pointer;
padding: 8px 0;
background-clip: content-box;
}
.progress-bar-fill {
height: 6px;
border-radius: 3px;
background: var(--accent);
pointer-events: none;
transition: width .5s linear;
margin-top: 8px;
}
.progress-bar-container:hover .progress-bar-fill { background: var(--accent); }
.progress-times {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text2);
}
/* Controls */
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
}
.ctrl-btn {
width: var(--control-size);
height: var(--control-size);
border: none;
background: none;
color: var(--text2);
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: color .15s, background .15s, transform .1s;
-webkit-tap-highlight-color: transparent;
}
.ctrl-btn svg { width: 26px; height: 26px; fill: currentColor; }
.ctrl-btn:hover { color: var(--text); background: rgba(255,255,255,0.1); }
.ctrl-btn:active { transform: scale(.9); }
.ctrl-btn.active { color: var(--accent); }
/* Prev / Next — slightly larger than secondary controls */
.ctrl-btn.ctrl-nav { width: 58px; height: 58px; }
.ctrl-btn.ctrl-nav svg { width: 30px; height: 30px; }
.ctrl-btn.primary {
width: 70px;
height: 70px;
background: var(--accent);
color: #000;
box-shadow: 0 4px 16px rgba(0,0,0,.35);
}
.ctrl-btn.primary svg { width: 36px; height: 36px; }
.ctrl-btn.primary:hover { filter: brightness(1.1); }
/* Volume */
.volume-row {
display: flex;
align-items: center;
gap: 10px;
}
.volume-row .ctrl-btn { width: 42px; height: 42px; flex-shrink: 0; }
.volume-row .ctrl-btn svg { width: 22px; height: 22px; }
input[type=range] {
flex: 1;
-webkit-appearance: none;
height: 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--text) 30%, transparent);
outline: none;
cursor: pointer;
touch-action: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
input[type=range]::-moz-range-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
border: none;
cursor: pointer;
}
/* Device picker */
.device-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg2);
border-radius: 8px;
cursor: pointer;
border: 1px solid var(--border);
transition: background .15s;
}
.device-row:hover { background: color-mix(in srgb, var(--bg2) 80%, white 20%); }
.device-row svg { width: 18px; height: 18px; fill: var(--text2); flex-shrink: 0; }
.device-name { flex: 1; font-size: 13px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.device-chevron { width: 16px; height: 16px; fill: var(--text2); flex-shrink: 0; }
/* Device modal */
.modal-overlay {
display: none;
position: absolute;
inset: 0;
background: rgba(0,0,0,.6);
z-index: 100;
align-items: flex-end;
justify-content: center;
border-radius: var(--radius);
}
.modal-overlay.open { display: flex; }
.modal-sheet {
width: 100%;
background: var(--bg2);
border-radius: var(--radius) var(--radius) 0 0;
padding: 16px;
max-height: 60%;
overflow-y: auto;
}
.modal-title {
font-size: 14px;
font-weight: 600;
color: var(--text2);
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title button { background: none; border: none; cursor: pointer; color: var(--text2); }
.modal-title button svg { width: 18px; height: 18px; fill: currentColor; display: block; }
.device-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
}
.device-item:hover { background: rgba(255,255,255,.06); }
.device-item.selected { color: var(--accent); }
.device-item.master { cursor: default; }
.device-item.master:hover { background: none; }
.device-item.member { color: var(--accent); opacity: .9; }
.device-item svg { width: 20px; height: 20px; fill: currentColor; }
.device-item-name { flex: 1; font-size: 14px; }
.device-item-state { font-size: 11px; color: var(--text2); }
/* Device modal sections */
.device-section + .device-section { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 4px; }
.device-section-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: var(--text2); padding: 10px 12px 4px; opacity: .65;
}
.device-item-action {
background: none; border: none; cursor: pointer; color: var(--text2); padding: 4px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
transition: color .15s, background .15s; flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.device-item-action:hover { color: var(--text); background: rgba(255,255,255,.12); }
.device-item-action svg { width: 16px; height: 16px; fill: currentColor; }
.device-item-action.attach { color: var(--accent); }
.device-item-action.detach:hover { color: #ff6b6b; background: rgba(255,107,107,.15); }
/* Device volume slider (inside group modal) */
.device-item-volume {
display: flex; align-items: center; gap: 6px;
padding: 2px 12px 8px 42px;
margin-top: -6px;
}
.device-item-volume svg { width: 14px; height: 14px; fill: var(--text2); flex-shrink: 0; }
.device-item-volume input[type=range] {
flex: 1; -webkit-appearance: none; height: 4px; border-radius: 2px;
background: color-mix(in srgb, var(--text) 25%, transparent);
outline: none; cursor: pointer; touch-action: none;
}
.device-item-volume input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); cursor: pointer;
}
.device-item-volume input[type=range]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); border: none; cursor: pointer;
}
.device-item-volume .device-vol-pct {
font-size: 11px; color: var(--text2); min-width: 28px; text-align: right;
}
/* ══════════════════════════════════════════
SEARCH TAB
══════════════════════════════════════════ */
.search-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.search-bar-wrapper {
padding: 12px 16px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.search-input-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border-radius: 8px;
padding: 8px 12px;
border: 1px solid var(--border);
}
.search-input-row svg { width: 18px; height: 18px; fill: var(--text2); flex-shrink: 0; }
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text);
font-size: 14px;
}
.search-input::placeholder { color: var(--text2); }
.filter-chips {
display: flex;
gap: 6px;
padding: 10px 16px;
overflow-x: auto;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
scrollbar-width: none;
}
.filter-chips::-webkit-scrollbar { display: none; }
.chip {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
border: 1px solid var(--border);
background: none;
color: var(--text2);
transition: all .15s;
}
.chip.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
font-weight: 600;
}
.chip:not(.active):hover { border-color: var(--text2); color: var(--text); }
.results-container { flex: 1; overflow-y: auto; padding: 8px 0; }
.result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
transition: background .15s;
}
.result-item:hover { background: rgba(255,255,255,.04); }
.result-thumb {
width: 44px;
height: 44px;
border-radius: 6px;
object-fit: cover;
background: var(--bg2);
flex-shrink: 0;
}
.result-thumb-placeholder {
width: 44px;
height: 44px;
border-radius: 6px;
background: var(--bg2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.result-thumb-placeholder svg { width: 22px; height: 22px; fill: var(--text2); }
.result-info { flex: 1; min-width: 0; }
.result-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.result-sub { font-size: 12px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.result-play {
width: 32px; height: 32px; border: none; background: none; cursor: pointer;
color: var(--text2); border-radius: 50%; display: flex; align-items: center; justify-content: center;
transition: color .15s, background .15s;
}
.result-play:hover { color: var(--accent); background: rgba(255,255,255,.06); }
.result-play svg { width: 18px; height: 18px; fill: currentColor; }
/* ── Browse mode ── */
.browse-mode-toggle {
display: flex;
gap: 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.browse-mode-btn {
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
background: none;
color: var(--text2);
border: none;
cursor: pointer;
transition: background .15s, color .15s;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
}
.browse-mode-btn:not(:last-child) { border-right: 1px solid var(--border); }
.browse-mode-btn:hover { background: rgba(255,255,255,.06); }
.browse-mode-btn.active { background: var(--accent); color: #000; }
.browse-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
padding: 8px 16px 4px;
font-size: 12px;
color: var(--text2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.browse-crumb {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
transition: background .15s;
-webkit-tap-highlight-color: transparent;
}
.browse-crumb:hover { background: rgba(255,255,255,.06); }
.browse-crumb.current { color: var(--text); cursor: default; }
.browse-crumb.current:hover { background: none; }
.browse-sep { color: var(--text2); opacity: .5; user-select: none; }
.browse-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
cursor: pointer;
transition: background .15s;
border-bottom: 1px solid rgba(255,255,255,.04);
}
.browse-item:hover { background: rgba(255,255,255,.04); }
.browse-item-icon {
width: 36px;
height: 36px;
border-radius: 6px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg2);
}
.browse-item-icon img { width: 36px; height: 36px; border-radius: 6px; object-fit: cover; }
.browse-item-icon svg { width: 20px; height: 20px; fill: var(--text2); }
.browse-item-icon.folder svg { fill: var(--accent); opacity: .8; }
.browse-item-info { flex: 1; min-width: 0; }
.browse-item-name { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.browse-item-sub { font-size: 12px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.browse-item-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.browse-play-btn {
width: 32px; height: 32px; border: none; background: none; cursor: pointer;
color: var(--text2); border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: color .15s, background .15s;
-webkit-tap-highlight-color: transparent;
}
.browse-play-btn:hover { color: var(--accent); background: rgba(255,255,255,.06); }
.browse-play-btn svg { width: 18px; height: 18px; fill: currentColor; }
.browse-chevron { color: var(--text2); opacity: .4; display: flex; align-items: center; }
.browse-chevron svg { width: 16px; height: 16px; fill: currentColor; }
.search-section { margin-bottom: 4px; }
.search-section-title {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--text2);
padding: 12px 16px 6px;
}
.search-card {
flex-shrink: 0;
width: 110px;
cursor: pointer;
border-radius: 8px;
padding: 8px;
transition: background .15s;
text-align: center;
}
.search-card:hover { background: rgba(255,255,255,.05); }
.search-card-art {
width: 94px;
height: 94px;
border-radius: 6px;
object-fit: cover;
background: var(--bg2);
display: block;
margin: 0 auto 6px;
}
.search-card-art-placeholder {
width: 94px;
height: 94px;
border-radius: 6px;
background: var(--bg2);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 6px;
}
.search-card-art-placeholder svg { width: 36px; height: 36px; fill: var(--text2); }
.search-card-art.round { border-radius: 50%; }
.search-card-art-placeholder.round { border-radius: 50%; }
.search-card-name {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-card-sub {
font-size: 11px;
color: var(--text2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.search-columns {
display: flex;
gap: 0;
height: 100%;
}
.search-column {
flex: 1;
min-width: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
}
.search-column:last-child { border-right: none; }
@media (max-width: 639px) {
.search-columns { flex-direction: column; height: auto; }
.search-column { border-right: none; border-bottom: 1px solid var(--border); overflow-y: visible; }
.search-column:last-child { border-bottom: none; }
}
.section-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text2);
padding: 12px 16px 4px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 16px;
color: var(--text2);
text-align: center;
}
.empty-state svg { width: 48px; height: 48px; fill: currentColor; opacity: .3; }
.empty-state p { font-size: 14px; }
/* ══════════════════════════════════════════
LIBRARY TAB
══════════════════════════════════════════ */
.library-panel { flex: 1; overflow-y: auto; padding: 0 0 16px; display: flex; flex-direction: column; }
.lib-filters {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.lib-filter-group {
display: flex;
gap: 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.lib-filter-btn {
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
background: none;
color: var(--text2);
border: none;
cursor: pointer;
transition: background .15s, color .15s;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
}
.lib-filter-btn:not(:last-child) { border-right: 1px solid var(--border); }
.lib-filter-btn:hover { background: rgba(255,255,255,.06); }
.lib-filter-btn.active { background: var(--accent); color: #000; }
.lib-filter-fav {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
font-size: 12px;
font-weight: 500;
background: none;
color: var(--text2);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
-webkit-tap-highlight-color: transparent;
}
.lib-filter-fav:hover { background: rgba(255,255,255,.06); }
.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-section { margin-bottom: 8px; }
.lib-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px 8px;
}
.lib-section-title { font-size: 16px; font-weight: 700; }
.lib-see-all { font-size: 12px; color: var(--accent); cursor: pointer; background: none; border: none; }
.lib-see-all:hover { text-decoration: underline; }
/* ── Lanes layout (horizontal scroll) ── */
.lib-scroll {
display: flex;
gap: 12px;
padding: 0 16px 4px;
overflow-x: auto;
scrollbar-width: none;
position: relative;
}
.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; }
.lib-scroll::-webkit-scrollbar { display: block; height: 6px; }
.lib-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,.2); border-radius: 3px; }
.lib-scroll::-webkit-scrollbar-track { background: transparent; }
}
.lib-sentinel { flex-shrink: 0; width: 1px; height: 1px; pointer-events: none; }
.lib-sentinel-v { height: 1px; pointer-events: none; }
/* Lane arrows (desktop only) */
.lib-lane-wrap { position: relative; }
.lib-lane-arrow {
display: none;
position: absolute;
top: 0;
bottom: 6px;
width: 36px;
z-index: 2;
border: none;
cursor: pointer;
align-items: center;
justify-content: center;
color: var(--text);
opacity: 0;
transition: opacity .2s;
}
.lib-lane-arrow svg { width: 20px; height: 20px; fill: currentColor; filter: drop-shadow(0 0 4px rgba(0,0,0,.6)); }
.lib-lane-arrow.left { left: 0; background: linear-gradient(to right, var(--bg1) 30%, transparent); padding-left: 4px; }
.lib-lane-arrow.right { right: 0; background: linear-gradient(to left, var(--bg1) 30%, transparent); padding-right: 4px; }
@media (hover: hover) and (pointer: fine) {
.lib-lane-arrow { display: flex; }
.lib-lane-wrap:hover .lib-lane-arrow.visible { opacity: 1; }
}
.lib-card {
flex-shrink: 0;
width: 120px;
cursor: pointer;
transition: transform .15s;
}
.lib-card:hover { transform: translateY(-2px); }
.lib-card-art {
width: 120px;
height: 120px;
border-radius: 8px;
object-fit: cover;
background: var(--bg2);
display: block;
}
.lib-card-art-placeholder {
width: 120px;
height: 120px;
border-radius: 8px;
background: var(--bg2);
display: flex;
align-items: center;
justify-content: center;
}
.lib-card-art-placeholder svg { width: 40px; height: 40px; fill: var(--text2); opacity: .4; }
.lib-card-name {
font-size: 12px;
font-weight: 500;
margin-top: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lib-card-sub { font-size: 11px; color: var(--text2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Grid layout ── */
.lib-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 16px 12px;
padding: 0 16px 4px;
}
.lib-grid .lib-card { width: auto; flex-shrink: unset; }
.lib-grid .lib-card-art { width: 100%; height: auto; aspect-ratio: 1; }
.lib-grid .lib-card-art-placeholder { width: 100%; height: auto; aspect-ratio: 1; }
/* ── Columns layout ── */
.lib-content.lib-layout-columns { overflow-y: hidden; }
.lib-columns-wrap {
display: flex;
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
}
.lib-columns-wrap > .lib-column {
flex: 1;
min-width: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
}
.lib-columns-wrap > .lib-column:last-child { border-right: none; }
.lib-column .lib-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
@media (max-width: 639px) {
.lib-columns-wrap { flex-direction: column; }
.lib-columns-wrap > .lib-column {
border-right: none;
border-bottom: 1px solid var(--border);
overflow-y: visible;
}
.lib-columns-wrap > .lib-column:last-child { border-bottom: none; }
}
/* List style for tracks */
.lib-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
cursor: pointer;
transition: background .15s;
}
.lib-list-item:hover { background: rgba(255,255,255,.04); }
.lib-list-thumb { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; background: var(--bg2); flex-shrink: 0; }
.lib-list-thumb-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg2); flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.lib-list-thumb-placeholder svg { width: 20px; height: 20px; fill: var(--text2); opacity: .4; }
.lib-list-info { flex: 1; min-width: 0; }
.lib-list-title { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lib-list-sub { font-size: 12px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── LOADING / ERROR ── */
.loader {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text2);
font-size: 14px;
gap: 10px;
}
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ═══════════════════════════════════════════
RESPONSIVE LAYOUT — TABLET (≥640px)
═══════════════════════════════════════════ */
/* ── Artist page ── */
.artist-page-panel { display: flex; flex-direction: column; overflow-y: auto; flex: 1; }
.artist-page-header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.back-btn { background: none; border: 1px solid var(--border); border-radius: 20px; color: var(--text); font-size: 13px; padding: 4px 12px; cursor: pointer; flex-shrink: 0; }
.back-btn:hover { background: rgba(255,255,255,.06); }
.artist-page-name { font-size: 18px; font-weight: 700; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artist-hero-art { width: 52px; height: 52px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.artist-hero-art-placeholder { width: 52px; height: 52px; border-radius: 50%; background: var(--bg2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.artist-hero-art-placeholder svg { width: 28px; height: 28px; fill: var(--text2); }
.artist-page-sections { overflow-y: auto; flex: 1; }
/* ── Queue (Up Next) — visual styles only, layout via media queries below ── */
.queue-section { overflow-y: auto; min-height: 0; min-width: 0; }
/* ═══════════════════════════════════════════
RESPONSIVE LAYOUT — MOBILE (<640px)
═══════════════════════════════════════════ */
@media (max-width: 639px) {
.queue-section { border-top: 1px solid var(--border); flex: 0 0 180px; }
}
/* ═══════════════════════════════════════════
RESPONSIVE LAYOUT — TABLET (≥640px)
═══════════════════════════════════════════ */
@media (max-width: 479px) {
.nav-tab { flex-direction: column; gap: 3px; padding: 8px 4px; font-size: 11px; }
}
@media (min-width: 640px) {
.player-panel { flex: 2; min-width: 0; }
.player-art-section { padding: 20px 24px 8px; }
.player-controls-section { padding: 8px 24px 20px; }
.track-title { font-size: 20px; }
.nav-tab span { display: inline; }
.player-tab-body { flex-direction: row; }
/* Queue: 1/3 width alongside player (2/3), fills full height, scrollable */
.queue-section { flex: 1; border-left: 1px solid var(--border); }
}
/* ═══════════════════════════════════════════
RESPONSIVE LAYOUT — DESKTOP (≥1024px)
═══════════════════════════════════════════ */
@media (min-width: 1024px) {
.track-title { font-size: 22px; }
.modal-sheet { max-height: 80%; }
}
.queue-header { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text2); padding: 10px 16px 4px; display: flex; align-items: center; justify-content: space-between; }
.queue-header-label { flex: 1; }
.queue-item { display: flex; align-items: center; gap: 10px; padding: 8px 16px; cursor: pointer; transition: background .15s; }
.queue-item:hover { background: rgba(255,255,255,.04); }
.queue-num { font-size: 12px; color: var(--text2); width: 18px; text-align: right; flex-shrink: 0; }
.queue-info { flex: 1; min-width: 0; }
.queue-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.queue-sub { font-size: 11px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.queue-dur { font-size: 11px; color: var(--text2); flex-shrink: 0; }
.queue-remove { background: none; border: none; cursor: pointer; padding: 4px; color: var(--text2); opacity: .6; flex-shrink: 0; display: flex; align-items: center; }
.queue-remove:active { opacity: 1; }
.queue-remove svg { width: 16px; height: 16px; fill: currentColor; }
.queue-empty { font-size: 12px; color: var(--text2); padding: 16px; text-align: center; opacity: .6; }
/* Queue toggle button (burger icon) */
.queue-toggle-btn { background: none; border: none; cursor: pointer; color: var(--text2); padding: 4px; display: flex; align-items: center; opacity: .7; transition: opacity .15s; position: absolute; right: 0; }
.queue-toggle-btn:active { opacity: 1; }
.queue-toggle-btn svg { width: 30px; height: 30px; fill: currentColor; }
.queue-toggle-btn.active { opacity: 1; color: var(--accent); }
/* Add-to-queue button on items */
.add-queue-btn { background: none; border: none; cursor: pointer; padding: 4px; color: var(--text2); flex-shrink: 0; display: flex; align-items: center; opacity: .6; position: relative; }
.add-queue-btn:active { opacity: 1; }
.add-queue-btn svg { width: 18px; height: 18px; fill: currentColor; }
/* Add-to-queue dropdown */
.queue-dropdown { position: absolute; z-index: 1000; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; min-width: 200px; box-shadow: 0 4px 16px rgba(0,0,0,.3); }
.queue-dropdown-item { padding: 8px 14px; font-size: 13px; color: var(--text); cursor: pointer; white-space: nowrap; }
.queue-dropdown-item:active { background: rgba(255,255,255,.08); }
.queue-dropdown-mix { border-top: 1px solid var(--border); margin-top: 2px; padding-top: 10px; display: flex; align-items: center; gap: 6px; }
.queue-dropdown-mix svg { width: 16px; height: 16px; fill: var(--accent); flex-shrink: 0; }
/* ══════════════════════════════════════════
SETTINGS MODAL
══════════════════════════════════════════ */
.settings-section-title {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: var(--text2); padding: 4px 0 10px; opacity: .65;
}
.settings-hint {
font-size: 12px; color: var(--text2); margin-bottom: 12px; line-height: 1.4; opacity: .7;
}
.provider-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0; border-bottom: 1px solid var(--border);
}
.provider-item:last-child { border-bottom: none; }
.provider-name { font-size: 14px; }
.toggle-switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
.toggle-switch input { display: none; }
.toggle-track {
position: absolute; inset: 0;
background: rgba(255,255,255,.15); border-radius: 22px;
cursor: pointer; transition: background .2s;
}
.toggle-track::after {
content: ""; position: absolute;
width: 16px; height: 16px; left: 3px; top: 3px;
background: white; border-radius: 50%;
transition: transform .2s; box-shadow: 0 1px 3px rgba(0,0,0,.3);
}
.toggle-switch input:checked + .toggle-track { background: var(--accent); }
.toggle-switch input:checked + .toggle-track::after { transform: translateX(18px); }
.settings-debug-banner {
display: flex; align-items: flex-start; gap: 10px;
background: rgba(255, 152, 0, .12); border: 1px solid rgba(255, 152, 0, .35);
border-radius: 8px; padding: 10px 12px; margin-bottom: 14px;
}
.settings-debug-dot {
width: 10px; height: 10px; min-width: 10px; border-radius: 50%;
background: #ff9800; margin-top: 3px;
animation: mml-debug-pulse 1.5s ease-in-out infinite;
}
@keyframes mml-debug-pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.mml-toast {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
background: var(--error-color, #b00020); color: #fff;
padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,.3); z-index: 999;
opacity: 0; transition: opacity .3s ease;
pointer-events: none; max-width: 90%; text-align: center;
}
.mml-toast.visible { opacity: 1; }
/* ═══════════════════════════════════════════
COMPANION MOBILE MODE
═══════════════════════════════════════════ */
/* Queue overlay backdrop */
.queue-backdrop {
display: none;
position: absolute;
inset: 0;
background: rgba(0,0,0,.55);
z-index: 49;
}
.queue-backdrop.open { display: block; }
.mml-mobile .player-tab-body { position: relative; }
/* Enlarged touch targets */
.mml-mobile .add-queue-btn {
width: 38px; height: 38px; min-width: 38px; padding: 0;
opacity: 1;
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-radius: 50%;
justify-content: center;
}
.mml-mobile .add-queue-btn:active { background: color-mix(in srgb, var(--accent) 35%, transparent); }
.mml-mobile .add-queue-btn svg { width: 20px; height: 20px; }
.mml-mobile .result-play { width: 44px; height: 44px; }
.mml-mobile .result-play svg { width: 22px; height: 22px; }
.mml-mobile .queue-remove {
width: 34px; height: 34px; min-width: 34px; padding: 0;
opacity: 1;
background: color-mix(in srgb, var(--text2) 15%, transparent);
border-radius: 50%;
justify-content: center;
}
.mml-mobile .queue-remove:active { background: color-mix(in srgb, var(--text2) 30%, transparent); }
.mml-mobile .queue-remove svg { width: 16px; height: 16px; }
.mml-mobile .queue-dropdown { min-width: 240px; border-radius: 12px; padding: 6px 0; }
.mml-mobile .queue-dropdown-item { padding: 16px 20px; font-size: 16px; }
.mml-mobile .browse-mode-btn { padding: 10px 16px; font-size: 13px; }
.mml-mobile .browse-play-btn { min-width: 44px; min-height: 44px; }
/* Larger slider thumbs and tracks */
.mml-mobile input[type=range] { height: 8px; border-radius: 4px; }
.mml-mobile input[type=range]::-webkit-slider-thumb { width: 28px; height: 28px; }
.mml-mobile input[type=range]::-moz-range-thumb { width: 28px; height: 28px; }
.mml-mobile .device-item-volume input[type=range] { height: 6px; }
.mml-mobile .device-item-volume input[type=range]::-webkit-slider-thumb { width: 22px; height: 22px; }
.mml-mobile .device-item-volume input[type=range]::-moz-range-thumb { width: 22px; height: 22px; }
/* Device modal action buttons (attach/detach) */
.mml-mobile .device-item-action {
width: 38px; height: 38px; min-width: 38px; padding: 0;
background: color-mix(in srgb, var(--text2) 15%, transparent);
}
.mml-mobile .device-item-action svg { width: 20px; height: 20px; }
.mml-mobile .device-item-action.attach {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
.mml-mobile .device-item-action.detach {
background: color-mix(in srgb, var(--text2) 15%, transparent);
}
/* Taller progress bar hit zone */
.mml-mobile .progress-bar-container { padding: 12px 0; }
/* More spacious controls */
.mml-mobile .controls { gap: 4px; }
.mml-mobile .ctrl-btn { min-width: 44px; min-height: 44px; }
.mml-mobile .ctrl-btn.ctrl-nav { width: 50px; height: 50px; }
.mml-mobile .ctrl-btn.primary { width: 62px; height: 62px; }
.mml-mobile .queue-toggle-btn { position: static; }
.mml-mobile .volume-row { gap: 14px; }
.mml-mobile .player-controls-section { gap: 16px; }
/* Queue as bottom-sheet overlay */
.mml-mobile .queue-section {
position: absolute !important;
bottom: 0; left: 0; right: 0;
max-height: 70%;
border-radius: 16px 16px 0 0;
background: var(--bg);
border-top: 1px solid var(--border);
border-left: none !important;
box-shadow: 0 -4px 24px rgba(0,0,0,.4);
z-index: 50;
flex: none !important;
transform: translateY(100%);
transition: transform .3s ease;
overflow-y: auto;
}
.mml-mobile .queue-section.mml-queue-open {
transform: translateY(0);
}
/* Queue close button */
.queue-close-btn {
background: none; border: none; cursor: pointer; color: var(--text2);
padding: 8px; display: flex; align-items: center; justify-content: center;
border-radius: 50%; transition: background .15s;
-webkit-tap-highlight-color: transparent;
}
.queue-close-btn:active { background: rgba(255,255,255,.12); }
.queue-close-btn svg { width: 20px; height: 20px; fill: currentColor; }
/* Queue items: larger touch targets */
.mml-mobile .queue-item { padding: 12px 16px; gap: 12px; }
.mml-mobile .queue-title { font-size: 14px; }
.mml-mobile .queue-sub { font-size: 12px; }
/* Result items: more breathing room */
.mml-mobile .result-item { padding: 12px 16px; }
.mml-mobile .browse-item { padding: 12px 16px; }
.mml-mobile .lib-list-item { padding: 10px 16px; }
`;
/* ─── Helpers ─────────────────────────────────────────────── */
function fmt(seconds) {
if (!seconds || isNaN(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function icon(name) {
return `${ICONS[name] || ""} `;
}
function throttle(fn, ms) {
let last = 0, timer = null, latestArgs = null;
return (...args) => {
latestArgs = args;
const now = Date.now();
if (now - last >= ms) {
last = now;
if (timer) { clearTimeout(timer); timer = null; }
fn(...args);
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn(...latestArgs);
}, ms - (now - last));
}
};
}
/* ─── Main Card Class ─────────────────────────────────────── */
class MyMusicLibraryCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._hass = null;
this._config = {};
this._tab = "player";
this._searchQuery = "";
this._searchFilter = "all";
this._searchResults = null;
this._searchLoading = false;
this._libData = {};
this._libLoading = false;
this._libLoadedTabs = new Set();
this._libSections = {}; // type → { offset, loading, exhausted, favorite, iconName }
this._libTabState = {}; // tabId → { source, fav, browse, browseStack }
this._maProviders = [];
this._enabledProviders = (() => {
try {
const s = this._loadPref("mml_providers");
if (!s) return null;
const set = new Set(JSON.parse(s));
set.delete("builtin");
return set.size > 0 ? set : null;
} catch (_) { return null; }
})();
this._deviceModalOpen = false;
this._searching = false;
this._searchTimeout = null;
this._searchId = 0;
this._players = [];
this._activePlayer = null;
this._progressInterval = null;
this._localPosition = null;
this._localPositionTime = null;
this._maQueueItems = [];
this._lastKnownUri = null;
this._queueVisible = this._loadPref("mml_queue_visible") !== "false";
this._groupMembers = []; // entity_ids attached to _activePlayer as group
this._deviceVolDragging = new Set();
this._excludedPlayers = []; // entity_ids hidden from the device picker (HA options)
// Per-tab library filter state is in this._libTabState[tabId]
this._rendered = false;
this._isMobile = this._detectMobile();
// MA config fetched from backend via WebSocket
this._maUrl = null; // stored but only used as a last-resort hint
this._maEntryId = null; // MA config entry ID — used for music_assistant/search WS calls
this._maConfigLoaded = false;
this._debugMode = false;
}
_debugLog(...args) {
if (this._debugMode) console.debug("[MML]", ...args);
}
_detectMobile() {
const isCompanion = !!window.externalApp || !!window.webkit?.messageHandlers?.getExternalAuth;
return isCompanion && window.innerWidth < 640;
}
/* ── i18n helper ── */
_t(key) {
const raw = this._hass?.locale?.language || this._hass?.language || "en";
const lang = raw.toLowerCase().split("-")[0];
const dict = TRANSLATIONS[lang] || TRANSLATIONS.en;
const val = key.split(".").reduce((o, k) => o?.[k], dict);
if (val !== undefined) return val;
return key.split(".").reduce((o, k) => o?.[k], TRANSLATIONS.en) ?? key;
}
/* ── MA Queue (via Music Assistant native queue) ── */
async _loadMAQueue() {
if (!this._activePlayer || !this._hass) { this._maQueueItems = []; this._updateQueueUI(); return; }
try {
const data = await this._callIntegration("GET", `ma_queue?player=${encodeURIComponent(this._activePlayer)}&limit=100`);
this._maQueueItems = data?.items || [];
this._debugLog("MA queue loaded →", this._maQueueItems.length, "items");
} catch (_) {
this._maQueueItems = [];
}
this._updateQueueUI();
}
_refreshQueueSoon(delay = 1200) {
clearTimeout(this._queueRefreshTimer);
this._queueRefreshTimer = setTimeout(() => this._loadMAQueue(), delay);
}
_updateQueueUI() {
const card = this.shadowRoot?.querySelector(".card-root");
if (card) this._updateQueueDisplay(card);
}
/* ── Group persistence (server-side, per player) ── */
/** Load group members for a given player from the HA backend. */
async _loadGroupFromServer(player) {
if (!player || !this._hass) { this._groupMembers = []; return; }
try {
const data = await this._callIntegration("GET", `groups?player=${encodeURIComponent(player)}`);
const stored = (data?.members || []).filter(id => this._players.find(p => p.entity_id === id));
// Reconcile with actual HA state: if HA has no group_members, the group was dissolved externally
const haState = this._hass.states[player];
const haMembers = (haState?.attributes?.group_members || []).filter(id => id !== player);
if (stored.length > 0 && haMembers.length === 0) {
this._groupMembers = [];
this._saveGroupToServer();
} else {
this._groupMembers = stored;
}
} catch (_) {
this._groupMembers = [];
}
const card = this.shadowRoot?.querySelector(".card-root");
if (card) this._updateDeviceRow(card);
}
/** Persist group members to the HA backend (fire-and-forget). */
_saveGroupToServer() {
if (!this._activePlayer || !this._hass) return;
this._callIntegration("POST", "groups", {
player: this._activePlayer,
members: this._groupMembers,
}).catch(() => {});
}
async _callIntegration(method, path, body) {
this._debugLog(`API ${method} /my_music_library/${path}`, body !== undefined ? body : "");
const opts = { method };
if (body !== undefined) {
opts.headers = { "Content-Type": "application/json" };
opts.body = JSON.stringify(body);
}
const resp = await this._hass.fetchWithAuth(`/my_music_library/${path}`, opts);
if (!resp.ok) {
const text = await resp.text().catch(() => "");
this._debugLog(`API ${method} /my_music_library/${path} → ${resp.status}:`, text);
throw new Error(`${resp.status}: ${text}`);
}
const data = await resp.json();
this._debugLog(`API ${method} /my_music_library/${path} → OK`, data);
return data;
}
/* ── Lovelace required ── */
setConfig(config) {
this._config = { default_tab: "player", ...config };
this._resolvedTabs = this._buildResolvedTabs(config);
const firstPanel = this._resolvedTabs.find(t => t.type !== "button");
const defaultTab = this._config.default_tab || (firstPanel ? firstPanel.id : "player");
this._tab = defaultTab;
if (config.height != null && config.height !== "") {
const h = typeof config.height === "number" ? `${config.height}px` : String(config.height);
this.style.setProperty("--mml-height", h);
}
}
_buildResolvedTabs(config) {
const DEFAULT_SECTIONS = ["artists", "albums", "playlists", "tracks"];
const VALID_SECTIONS = ["artists", "albums", "playlists", "tracks", "radios", "recently_played", "recently_added", "recommended", "flows"];
const TAB_ICONS = { player: "player", search: "search", library: "library", settings: "settings" };
if (config.tabs && Array.isArray(config.tabs)) {
let idx = 0;
const typeCounts = {};
return config.tabs.map(t => {
const type = t.type || "button";
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,
width: t.width, height: t.height };
}
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 };
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;
const VALID_LAYOUTS = ["lanes", "grid", "columns", "auto"];
tab.layout = VALID_LAYOUTS.includes(t.layout) ? t.layout : "lanes";
}
if (type === "search") {
tab.search_layout = t.search_layout === "columns" ? "columns" : "rows";
}
return tab;
});
}
// Backward compatibility: build from legacy config
const tabs = [];
if (config.nav_buttons_left) {
let idx = 0;
for (const b of config.nav_buttons_left) {
tabs.push({ type: "button", id: `btn-${idx++}`, icon: b.icon, name: b.name || "", entity: b.entity,
tap_action: b.tap_action, hold_action: b.hold_action, double_tap_action: b.double_tap_action,
width: b.width, height: b.height });
}
}
tabs.push({ type: "player", id: "player", label: null, iconOverride: null, defaultIcon: "player" });
tabs.push({ type: "search", id: "search", label: null, iconOverride: null, defaultIcon: "search", search_layout: "rows" });
tabs.push({ type: "library", id: "library", label: null, iconOverride: null, defaultIcon: "library",
sections: DEFAULT_SECTIONS, layout: "lanes" });
if (config.nav_buttons_right) {
let idx = (config.nav_buttons_left?.length || 0);
for (const b of config.nav_buttons_right) {
tabs.push({ type: "button", id: `btn-${idx++}`, icon: b.icon, name: b.name || "", entity: b.entity,
tap_action: b.tap_action, hold_action: b.hold_action, double_tap_action: b.double_tap_action,
width: b.width, height: b.height });
}
}
tabs.push({ type: "settings", id: "settings", label: null, iconOverride: null, defaultIcon: "settings" });
return tabs;
}
// Tell Lovelace masonry how many rows to reserve (1 row ≈ 50px)
getCardSize() {
const h = this._config?.height;
if (h && typeof h === "number") return Math.ceil(h / 50);
if (h && typeof h === "string" && h.endsWith("px")) return Math.ceil(parseInt(h) / 50);
return 8; // default ~400px
}
static getConfigElement() {
return document.createElement("my-music-library-card-editor");
}
static getStubConfig() {
return { default_tab: "player" };
}
set hass(hass) {
this._hass = hass;
// Fetch integration config from backend once
if (!this._maConfigLoaded) {
this._maConfigLoaded = true;
this._fetchMaConfig();
}
this._players = this._getMaPlayers();
if (this._players.length === 0) {
this._activePlayer = null;
} else {
if (!this._activePlayer || !this._players.find(p => p.entity_id === this._activePlayer)) {
const saved = this._loadSavedPlayer();
const prevActive = this._activePlayer;
this._activePlayer = (saved && this._players.find(p => p.entity_id === saved) ? saved : null)
|| this._config.entity
|| (this._players.find(p => p.state === "playing") || this._players[0])?.entity_id;
this._debugLog("Player selected:", this._activePlayer, "prev:", prevActive, "saved:", saved, "players:", this._players.map(p => p.entity_id));
if (this._activePlayer && this._activePlayer !== prevActive) {
this._loadMAQueue();
this._loadGroupFromServer(this._activePlayer);
}
}
}
if (!this._rendered) {
this._render();
this._rendered = true;
} else {
this._update();
}
}
connectedCallback() {
this._startProgressTick();
// hui-card (HA wrapper) has auto height by default — force it to fill its grid cell
// so our height:100% resolves to the actual allocated height instead of auto.
requestAnimationFrame(() => {
let p = this.parentNode;
while (p && p.tagName) {
if (p.tagName.toLowerCase() === "hui-card") {
p.style.height = "100%";
p.style.display = "block";
break;
}
p = p.parentNode;
}
});
}
disconnectedCallback() {
this._stopProgressTick();
// Force config re-fetch on next reconnect so excluded_players stays in sync
// with any options changes made while the card was away from the DOM.
this._maConfigLoaded = false;
}
/* ── Fetch integration config (ma_entry_id, ma_url) via WebSocket ── */
async _fetchMaConfig() {
try {
const cfg = await this._hass.callWS({ type: "my_music_library/config" });
this._debugMode = !!cfg?.debug_mode;
this._debugLog("Config loaded:", JSON.stringify(cfg));
if (cfg?.ma_entry_id) {
this._maEntryId = cfg.ma_entry_id;
}
await this._fetchProviders();
if (cfg?.ma_url) {
this._maUrl = cfg.ma_url.replace(/\/$/, "");
}
if (Array.isArray(cfg?.excluded_players)) {
this._excludedPlayers = cfg.excluded_players;
this._players = this._getMaPlayers();
const card = this.shadowRoot?.querySelector(".card-root");
if (card) this._updatePlayerContent(card);
}
} catch (e) {
this._debugLog("Config fetch failed:", e);
}
}
async _fetchProviders() {
try {
const data = await this._callIntegration("GET", "providers");
this._maProviders = (data?.providers || []).filter(p => (p.domain || p.instance_id) !== "builtin");
if (this._enabledProviders !== null && this._maProviders.length > 0) {
const validKeys = new Set(this._maProviders.flatMap(p => [p.instance_id, p.domain].filter(Boolean)));
const hasAnyValid = [...this._enabledProviders].some(k => validKeys.has(k));
if (!hasAnyValid) {
this._enabledProviders = null;
this._savePref("mml_providers", "");
}
}
if (this._libLoadedTabs.size > 0) {
this._libLoadedTabs.clear();
const activeTabDef = this._resolvedTabs?.find(t => t.id === this._tab);
if (activeTabDef?.type === "library") this._loadLibrary();
}
} catch (_) {
this._maProviders = [];
}
}
/* ── Return an MA-capable entity for browse/search operations ──
This is DIFFERENT from _activePlayer (which is the playback target).
Browse operations (search, library) must go through an MA entity. ── */
_getBrowseEntity() {
const players = this._players || [];
// Prefer: active player if it's an MA player
if (this._activePlayer) {
const active = players.find(p => p.entity_id === this._activePlayer);
if (active?.isMa) return this._activePlayer;
}
// Fall back to first detected MA player
const firstMa = players.find(p => p.isMa);
if (firstMa) return firstMa.entity_id;
return null;
}
/* ── Get media_player entities (all non-unavailable, MA players first) ── */
/** Return true if entityId matches a plain ID or a glob pattern (supports *). */
_isExcluded(entityId) {
for (const pattern of (this._excludedPlayers || [])) {
if (!pattern) continue;
if (pattern.includes("*")) {
// Glob → regex: escape regex meta-chars except *, then replace * with .*
const re = new RegExp(
"^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"
);
// Test against full entity_id AND against the name part (after "media_player.")
if (re.test(entityId) || re.test(entityId.replace(/^media_player\./, ""))) return true;
} else {
if (pattern === entityId) return true;
}
}
return false;
}
_getMaPlayers() {
if (!this._hass) return [];
// MediaPlayerEntityFeature.GROUPING = 524288 (bit 19)
const FEATURE_GROUPING = 524288;
const all = Object.entries(this._hass.states)
.filter(([id, state]) => id.startsWith("media_player.") && state.state !== "unavailable" && !this._isExcluded(id))
.map(([entity_id, state]) => {
const attr = state.attributes || {};
// isMa: used for browse/search operations (requires MA Python client)
const isMa = typeof attr.mass_player_id === "string" && attr.mass_player_id.length > 0;
// canJoin: player declares support for media_player.join in HA supported_features
const canJoin = typeof attr.supported_features === "number"
&& (attr.supported_features & FEATURE_GROUPING) !== 0;
return {
entity_id,
name: attr.friendly_name || entity_id,
state: state.state,
attributes: attr,
isMa,
canJoin,
};
});
return all
// MA players first, then alphabetical within each group
.sort((a, b) => {
if (a.isMa !== b.isMa) return a.isMa ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
_getActiveState() {
if (!this._hass || !this._activePlayer) return null;
return this._hass.states[this._activePlayer] || null;
}
/* ── Render full card ── */
_render() {
const root = this.shadowRoot;
root.innerHTML = "";
const style = document.createElement("style");
style.textContent = STYLES;
root.appendChild(style);
this._isMobile = this._detectMobile();
const card = document.createElement("div");
card.className = `card-root${this._isMobile ? " mml-mobile" : ""}`;
const panels = this._resolvedTabs.filter(t => t.type !== "button");
const panelRenderers = {
player: (t) => this._renderPlayerTab(),
search: (t) => this._renderSearchTab(),
library: (t) => this._renderLibraryTab(t),
};
card.innerHTML = `
${this._renderNav()}
${panels.map(t => panelRenderers[t.type] ? panelRenderers[t.type](t) : "").join("")}
${this._renderDeviceModal()}
${this._renderSettingsModal()}
`;
root.appendChild(card);
if (!this._imgErrorBound) {
this._imgErrorBound = true;
root.addEventListener("error", (e) => {
const img = e.target;
if (!(img instanceof HTMLImageElement) || img.classList.contains("art")) return;
const classes = img.className.split(" ").filter(Boolean);
const primary = classes[0] || "result-thumb";
const isRound = classes.includes("round");
const ph = document.createElement("div");
ph.className = [primary + "-placeholder", ...classes.slice(1)].join(" ");
ph.innerHTML = isRound ? ICONS.artist : ICONS.music;
img.replaceWith(ph);
}, true);
}
this._attachListeners(card);
this._setActiveTab(this._tab, card);
this._updatePlayerContent(card);
}
_renderNav() {
const items = this._resolvedTabs.map(t => {
if (t.type === "button") {
return this._renderNavButton(t);
}
if (t.type === "settings") {
const label = t.label || this._t("tabs.settings");
const icon = t.iconOverride
? ` `
: ICONS.settings;
const debugStyle = this._debugMode ? ' style="color: orange;"' : '';
return `
${icon}${this._esc(label)}
`;
}
const label = t.label || this._t(`tabs.${t.type}`);
const icon = t.iconOverride
? ` `
: (ICONS[t.defaultIcon] || ICONS.player);
return `
${icon}${this._esc(label)}
`;
}).join("");
return `
`;
}
_renderNavButton(btn) {
const entity = btn.entity ? this._hass?.states[btn.entity] : null;
const isActive = entity
? ["on", "playing", "active", "home"].includes(entity.state)
: false;
const icon = btn.icon || entity?.attributes?.icon || "mdi:gesture-tap";
const label = btn.name || "";
const title = label || entity?.attributes?.friendly_name || "";
const sizeParts = [];
if (btn.width) sizeParts.push(`width:${typeof btn.width === "number" ? btn.width + "px" : btn.width}`);
if (btn.height) sizeParts.push(`height:${typeof btn.height === "number" ? btn.height + "px" : btn.height}`);
const sizeStyle = sizeParts.length ? ` style="${sizeParts.join(";")}"` : "";
return `
${label ? `${this._esc(label)} ` : ""}
`;
}
_renderPlayerTab() {
return `
—
${this._t("player.select_player")}
${ICONS.shuffle}
${ICONS.prev}
${ICONS.play}
${ICONS.next}
${ICONS.repeat}
${ICONS.queue}
${ICONS.volumeHigh}
${ICONS.device}
${this._t("player.no_player")}
${ICONS.chevronRight}
${this._t("queue.empty")}
`;
}
_renderSearchTab() {
return `
${ICONS.search}
${this._t("search.type_hint")}
`;
}
_renderLibraryTab(tabDef) {
const panelId = tabDef?.id || "library";
const st = this._getLibTabState(panelId);
const src = st.source;
const fav = st.fav;
const browse = st.browse;
return `
${this._t("lib.filter_all")}
${this._t("lib.filter_local")}
${this._t("lib.filter_streaming")}
${this._t("lib.mode_catalogue")}
${this._t("lib.mode_browse")}
${fav ? ICONS.heart : ICONS.heartOutline}${this._t("lib.filter_favorites")}
${this._t("lib.loading")}
`;
}
_renderDeviceModal() {
return `
${this._t("group.title")}
${ICONS.close}
`;
}
_renderSettingsModal() {
return `
${this._t("settings.title")}
${ICONS.close}
v${CARD_VERSION}
`;
}
/* ── Event Listeners ── */
_attachListeners(card) {
// Nav tabs (panel tabs + settings)
card.querySelectorAll(".nav-tab").forEach(btn => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
if (tab === "settings") {
this._openSettings(card);
return;
}
this._setActiveTab(tab, card);
const tabDef = this._resolvedTabs.find(t => t.id === tab);
if (tabDef?.type === "library" && !this._libLoadedTabs.has(tab)) this._loadLibrary();
});
});
// Nav scroll fade indicators
const navEl = card.querySelector(".nav");
if (navEl) {
const fadeL = card.querySelector(".nav-fade-left");
const fadeR = card.querySelector(".nav-fade-right");
const updateFades = () => {
const { scrollLeft, scrollWidth, clientWidth } = navEl;
fadeL?.classList.toggle("visible", scrollLeft > 2);
fadeR?.classList.toggle("visible", scrollLeft + clientWidth < scrollWidth - 2);
};
navEl.addEventListener("scroll", updateFades, { passive: true });
requestAnimationFrame(updateFades);
}
// Library filters — attach per panel so multi-library-tab setups work independently
for (const libPanel of card.querySelectorAll('.tab-panel')) {
const tabId = libPanel.dataset.panel;
const tabDef = this._resolvedTabs.find(t => t.id === tabId);
if (tabDef?.type !== "library") continue;
const ts = this._getLibTabState(tabId);
libPanel.querySelector(".lib-filter-group")?.addEventListener("click", (e) => {
const btn = e.target.closest(".lib-filter-btn");
if (!btn || btn.classList.contains("active")) return;
const source = btn.dataset.source;
ts.source = source;
this._savePref(`mml_lib_source_${tabId}`, source);
if (source !== "local") ts.browse = false;
libPanel.querySelectorAll(".lib-filter-btn").forEach(b => b.classList.toggle("active", b.dataset.source === source));
this._libLoadedTabs.delete(tabId);
if (this._tab === tabId) this._reloadLibrary();
});
libPanel.querySelector(".browse-mode-toggle")?.addEventListener("click", (e) => {
const btn = e.target.closest(".browse-mode-btn");
if (!btn || btn.classList.contains("active")) return;
ts.browse = btn.dataset.browse === "true";
ts.browseStack = [];
this._libLoadedTabs.delete(tabId);
if (this._tab === tabId) this._reloadLibrary();
});
libPanel.querySelector(".lib-filter-fav")?.addEventListener("click", () => {
ts.fav = !ts.fav;
this._savePref(`mml_lib_fav_${tabId}`, String(ts.fav));
const favEl = libPanel.querySelector(".lib-filter-fav");
if (favEl) {
favEl.classList.toggle("active", ts.fav);
favEl.querySelector("svg").outerHTML = ts.fav ? ICONS.heart : ICONS.heartOutline;
}
this._libLoadedTabs.delete(tabId);
if (this._tab === tabId) this._reloadLibrary();
});
}
const hasPanel = (type) => this._resolvedTabs.some(t => t.type === type);
// Player controls (only if player tab is present)
if (hasPanel("player")) {
card.querySelector("#btn-playpause").addEventListener("click", () => this._togglePlayPause());
card.querySelector("#btn-prev").addEventListener("click", () => this._callService("media_previous_track"));
card.querySelector("#btn-next").addEventListener("click", () => this._callService("media_next_track"));
card.querySelector("#btn-shuffle").addEventListener("click", () => this._toggleShuffle());
card.querySelector("#btn-repeat").addEventListener("click", () => this._cycleRepeat());
card.querySelector("#btn-mute").addEventListener("click", () => this._toggleMute());
// Volume — send command only on release (pointerup), not during drag
const volSlider = card.querySelector("#volume-slider");
volSlider.addEventListener("pointerdown", () => { this._volumeDragging = true; });
const endVolDrag = (e) => {
if (!this._volumeDragging) return;
this._volumeDragging = false;
this._callService("volume_set", { volume_level: parseInt(e.target.value) / 100 });
};
volSlider.addEventListener("pointerup", endVolDrag);
volSlider.addEventListener("pointercancel", () => { this._volumeDragging = false; });
// Progress bar — seek on release only (covers both tap and drag)
const progressBar = card.querySelector("#progress-bar");
const progressFill = card.querySelector("#progress-fill");
const posTimeEl = card.querySelector("#pos-time");
const getSeekPct = (e) => {
const rect = progressBar.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
};
progressBar.addEventListener("pointerdown", (e) => {
const state = this._getActiveState();
if (!state?.attributes?.media_duration) return;
this._seekDragging = true;
progressBar.setPointerCapture(e.pointerId);
progressFill.style.transition = "none";
const pct = getSeekPct(e);
progressFill.style.width = `${(pct * 100).toFixed(1)}%`;
if (posTimeEl) posTimeEl.textContent = fmt(pct * state.attributes.media_duration);
});
progressBar.addEventListener("pointermove", (e) => {
if (!this._seekDragging) return;
const state = this._getActiveState();
if (!state?.attributes?.media_duration) return;
const pct = getSeekPct(e);
progressFill.style.width = `${(pct * 100).toFixed(1)}%`;
if (posTimeEl) posTimeEl.textContent = fmt(pct * state.attributes.media_duration);
});
const endSeekDrag = (e) => {
if (!this._seekDragging) return;
this._seekDragging = false;
progressFill.style.transition = "";
const state = this._getActiveState();
if (!state?.attributes?.media_duration) return;
const pct = getSeekPct(e);
const pos = pct * state.attributes.media_duration;
this._callService("media_seek", { seek_position: Math.round(pos) });
this._localPosition = pos;
this._localPositionTime = Date.now() / 1000;
};
progressBar.addEventListener("pointerup", endSeekDrag);
progressBar.addEventListener("pointercancel", () => {
this._seekDragging = false;
progressFill.style.transition = "";
});
// Queue toggle
card.querySelector("#btn-queue-toggle")?.addEventListener("click", (e) => {
e.stopPropagation();
this._queueVisible = !this._queueVisible;
this._savePref("mml_queue_visible", String(this._queueVisible));
this._applyQueueVisibility(card);
});
// Queue close (mobile: backdrop tap or close button)
const closeQueue = () => {
this._queueVisible = false;
this._savePref("mml_queue_visible", "false");
this._applyQueueVisibility(card);
};
card.querySelector("#queue-backdrop")?.addEventListener("click", closeQueue);
card.querySelector("#queue-close-btn")?.addEventListener("click", closeQueue);
// Device row
card.querySelector("#device-row").addEventListener("click", () => this._openDeviceModal(card));
card.querySelector("#modal-close").addEventListener("click", () => this._closeDeviceModal(card));
card.querySelector("#device-modal").addEventListener("click", (e) => {
if (e.target === card.querySelector("#device-modal")) this._closeDeviceModal(card);
});
}
// Settings modal close
card.querySelector("#settings-close").addEventListener("click", () => this._closeSettings(card));
card.querySelector("#settings-modal").addEventListener("click", (e) => {
if (e.target === card.querySelector("#settings-modal")) this._closeSettings(card);
});
// Search (only if search tab is present)
const searchInput = card.querySelector("#search-input");
searchInput?.addEventListener("input", (e) => {
clearTimeout(this._searchTimeout);
this._searchQuery = e.target.value;
if (this._searchQuery.trim().length < 2) {
this._renderSearchResults(card, null);
return;
}
this._searchTimeout = setTimeout(() => this._doSearch(card), 700);
});
// Nav action buttons (tap / hold / double-tap → HA actions)
card.querySelectorAll("[data-tab-btn]").forEach(btn => {
let holdTimer = null;
let didHold = false;
const getBtnCfg = () => {
const id = btn.dataset.tabBtn;
return this._resolvedTabs.find(t => t.id === id);
};
btn.addEventListener("pointerdown", () => {
didHold = false;
const cfg = getBtnCfg();
if (!cfg?.hold_action) return;
holdTimer = setTimeout(() => {
didHold = true;
this._handleNavAction(cfg, "hold_action");
}, 500);
});
btn.addEventListener("pointerup", () => clearTimeout(holdTimer));
btn.addEventListener("pointercancel", () => clearTimeout(holdTimer));
btn.addEventListener("click", () => {
if (didHold) { didHold = false; return; }
const cfg = getBtnCfg();
if (cfg) this._handleNavAction(cfg, "tap_action");
});
btn.addEventListener("dblclick", (e) => {
e.stopPropagation();
const cfg = getBtnCfg();
if (cfg?.double_tap_action) this._handleNavAction(cfg, "double_tap_action");
});
});
}
/* ── Nav button action handler ── */
_handleNavAction(btnCfg, actionKey) {
const action = btnCfg[actionKey];
if (!action || action.action === "none") return;
switch (action.action) {
case "call-service":
case "perform-action": {
// HA 2024.8+ uses "perform_action"; older uses "service"
const svcStr = action.perform_action || action.service || "";
const [domain, service] = svcStr.split(".", 2);
if (domain && service) {
this._hass.callService(
domain, service,
action.service_data || action.data || {},
action.target || {}
);
}
break;
}
case "toggle": {
const entityId = action.entity_id || btnCfg.entity;
if (entityId) {
const st = this._hass.states[entityId];
const dom = entityId.split(".")[0];
const svc = st?.state === "on" ? "turn_off" : "turn_on";
this._hass.callService(dom, svc, {}, { entity_id: entityId });
}
break;
}
case "more-info": {
const entityId = action.entity_id || btnCfg.entity;
if (entityId) {
this.dispatchEvent(new CustomEvent("hass-more-info", {
detail: { entityId },
bubbles: true,
composed: true,
}));
}
break;
}
case "navigate": {
const path = action.navigation_path || "/";
history.pushState(null, "", path);
this.dispatchEvent(new CustomEvent("location-changed", {
detail: { replace: false },
bubbles: true,
composed: true,
}));
break;
}
case "url": {
const url = action.url_path || action.url || "";
if (url) window.open(url, action.new_tab !== false ? "_blank" : "_self");
break;
}
case "assist": {
this.dispatchEvent(new CustomEvent("show-dialog", {
detail: { dialogTag: "ha-voice-command-dialog", dialogImport: () => {} },
bubbles: true,
composed: true,
}));
break;
}
default:
break;
}
}
/* ── Tab switching ── */
_setActiveTab(tab, card) {
this._tab = tab;
card.querySelectorAll(".nav-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
card.querySelectorAll(".tab-panel").forEach(p => p.classList.toggle("active", p.dataset.panel === tab));
}
/* ── Update (called on every hass update) ── */
_update() {
const card = this.shadowRoot.querySelector(".card-root");
if (!card) return;
this._updatePlayerContent(card);
this._updateNavButtons(card);
const activeTabDef = this._resolvedTabs.find(t => t.id === this._tab);
if (activeTabDef?.type === "library" && !this._libLoadedTabs.has(this._tab)) this._loadLibrary();
}
_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);
}
}
_resolveImageUrl(url) {
if (!url) return "";
if (!url.startsWith("http")) {
const hasHassUrl = typeof this._hass?.hassUrl === "function";
return hasHassUrl ? this._hass.hassUrl(url) : url;
}
const isMixedContent = location.protocol === "https:" && url.startsWith("http://");
if (isMixedContent) {
return `/my_music_library/image_proxy?url=${encodeURIComponent(url)}`;
}
return url;
}
_updatePlayerContent(card) {
const state = this._getActiveState();
const attr = state?.attributes || {};
const isPlaying = state?.state === "playing";
// Album art
const artWrapper = card.querySelector("#art-wrapper");
if (artWrapper) {
if (attr.entity_picture) {
const ep = attr.entity_picture;
const src = this._resolveImageUrl(ep);
console.debug("[MML] Cover art: entity_picture=%s → src=%s", ep, src);
const existing = artWrapper.querySelector("img.art");
if (!existing || existing.src !== src) {
artWrapper.innerHTML = "";
const img = document.createElement("img");
img.className = "art";
img.alt = "Album art";
img.src = src;
img.onload = () => console.debug("[MML] Cover art loaded OK: %s", img.src);
img.onerror = () => {
console.warn("[MML] Cover art FAILED: %s", img.src);
artWrapper.innerHTML = `${ICONS.music}
`;
};
artWrapper.appendChild(img);
}
} else {
artWrapper.innerHTML = `${ICONS.music}
`;
}
}
// Track info
const titleEl = card.querySelector("#track-title");
const artistEl = card.querySelector("#track-artist");
if (titleEl) titleEl.textContent = attr.media_title || (state ? this._t("player.nothing_playing") : "—");
if (artistEl) {
artistEl.textContent = [attr.media_artist, attr.media_album_name].filter(Boolean).join(" · ") || this._t("player.select_player");
}
// Play/pause button
const ppBtn = card.querySelector("#btn-playpause");
if (ppBtn) ppBtn.innerHTML = isPlaying ? ICONS.pause : ICONS.play;
// Shuffle
const shuffleBtn = card.querySelector("#btn-shuffle");
if (shuffleBtn) shuffleBtn.classList.toggle("active", !!attr.shuffle);
// Repeat
const repeatBtn = card.querySelector("#btn-repeat");
if (repeatBtn) {
const repeat = attr.repeat || "off";
repeatBtn.innerHTML = repeat === "one" ? ICONS.repeatOne : ICONS.repeat;
repeatBtn.classList.toggle("active", repeat !== "off");
}
// Volume — skip update while user is dragging to prevent snap-back
const volSlider = card.querySelector("#volume-slider");
if (volSlider && attr.volume_level !== undefined && !this._volumeDragging) {
volSlider.value = Math.round(attr.volume_level * 100);
}
// Mute
const muteBtn = card.querySelector("#btn-mute");
if (muteBtn) {
muteBtn.innerHTML = attr.is_volume_muted ? ICONS.volumeMute : ICONS.volumeHigh;
muteBtn.classList.toggle("active", !!attr.is_volume_muted);
}
// Progress
this._updateProgress(card, state);
// Device row (name + icon reflect group state)
this._updateDeviceRow(card);
// Live-update volume sliders inside the device/group modal
this._updateDeviceModalVolumes(card);
// Refresh MA queue when the current track changes
const currentUri = attr.media_content_id || null;
if (currentUri !== this._lastKnownUri) {
this._lastKnownUri = currentUri;
this._refreshQueueSoon(800);
}
}
_updateProgress(card, state) {
const attr = state?.attributes || {};
const dur = attr.media_duration || 0;
const isPlaying = state?.state === "playing";
let pos;
if (isPlaying && this._localPosition !== null) {
const elapsed = Date.now() / 1000 - this._localPositionTime;
pos = this._localPosition + elapsed;
} else if (isPlaying) {
const statePos = attr.media_position || 0;
const posUpdated = attr.media_position_updated_at;
if (posUpdated) {
const elapsed = (Date.now() - new Date(posUpdated).getTime()) / 1000;
pos = statePos + elapsed;
} else {
pos = statePos;
}
} else {
pos = attr.media_position || 0;
this._localPosition = null;
}
pos = Math.min(pos || 0, dur);
const pct = dur > 0 ? (pos / dur) * 100 : 0;
const fill = card.querySelector("#progress-fill");
const posTime = card.querySelector("#pos-time");
const durTime = card.querySelector("#dur-time");
if (fill && !this._seekDragging) fill.style.width = `${pct.toFixed(1)}%`;
if (posTime && !this._seekDragging) posTime.textContent = fmt(pos);
if (durTime) durTime.textContent = fmt(dur);
}
/* ── Progress ticker ── */
_startProgressTick() {
this._stopProgressTick();
this._progressInterval = setInterval(() => {
const card = this.shadowRoot?.querySelector(".card-root");
if (!card) return;
const state = this._getActiveState();
if (state?.state === "playing") this._updateProgress(card, state);
}, 1000);
}
_stopProgressTick() {
if (this._progressInterval) { clearInterval(this._progressInterval); this._progressInterval = null; }
}
/* ── Service calls ── */
_callService(service, data = {}) {
if (!this._hass || !this._activePlayer) return;
this._hass.callService("media_player", service, {
entity_id: this._activePlayer,
...data,
});
}
_togglePlayPause() {
const state = this._getActiveState();
if (!state) return;
this._callService(state.state === "playing" ? "media_pause" : "media_play");
}
_toggleShuffle() {
const state = this._getActiveState();
if (!state) return;
this._callService("shuffle_set", { shuffle: !state.attributes.shuffle });
}
_cycleRepeat() {
const state = this._getActiveState();
if (!state) return;
const modes = ["off", "all", "one"];
const cur = state.attributes.repeat || "off";
const next = modes[(modes.indexOf(cur) + 1) % modes.length];
this._callService("repeat_set", { repeat: next });
}
_toggleMute() {
this._callService("volume_mute", { is_volume_muted: !this._getActiveState()?.attributes?.is_volume_muted });
}
/* ── Device row ── */
_updateDeviceRow(card) {
const state = this._getActiveState();
const attr = state?.attributes || {};
const name = attr.friendly_name || this._activePlayer || this._t("player.no_player");
const count = this._groupMembers.length;
const nameEl = card.querySelector("#device-name");
if (nameEl) nameEl.textContent = count > 0 ? `${name} +${count}` : name;
const iconWrap = card.querySelector("#device-icon-wrap");
if (iconWrap) iconWrap.innerHTML = count > 0 ? ICONS.group : ICONS.device;
}
/* ── Device modal ── */
_openDeviceModal(card) {
const modal = card.querySelector("#device-modal");
const list = card.querySelector("#device-list");
list.innerHTML = "";
if (this._players.length === 0) {
list.innerHTML = `${this._t("group.no_players")}
`;
modal.classList.add("open");
return;
}
const addSection = (title, entries) => {
if (!entries.length) return;
const section = document.createElement("div");
section.className = "device-section";
section.innerHTML = `${title}
`;
entries.forEach(({ player, role }) => section.appendChild(this._buildDeviceItem(player, role, card)));
list.appendChild(section);
};
const masterPlayer = this._players.find(p => p.entity_id === this._activePlayer);
const members = this._players.filter(p => this._groupMembers.includes(p.entity_id));
const available = this._players.filter(p => p.entity_id !== this._activePlayer && !this._groupMembers.includes(p.entity_id));
if (masterPlayer) addSection(this._t("group.section_master"), [{ player: masterPlayer, role: "master" }]);
addSection(this._t("group.section_members"), members.map(p => ({ player: p, role: "member" })));
addSection(this._t("group.section_available"), available.map(p => ({ player: p, role: "available" })));
modal.classList.add("open");
}
_buildDeviceItem(player, role, card) {
const frag = document.createDocumentFragment();
const item = document.createElement("div");
item.className = `device-item${role === "master" ? " selected master" : role === "member" ? " member" : ""}`;
const iconSvg = role === "member" ? ICONS.group : ICONS.device;
const canGroup = player.canJoin;
let actionHtml = "";
if (role === "member") {
actionHtml = `${ICONS.close} `;
} else if (role === "available" && canGroup) {
actionHtml = `${ICONS.plus} `;
}
item.innerHTML = `
${iconSvg}
${this._esc(player.name)}
${this._esc(player.state)}
${actionHtml}`;
frag.appendChild(item);
if (role === "master" || role === "member") {
const volState = this._hass?.states[player.entity_id];
const volLevel = volState?.attributes?.volume_level;
const volPct = volLevel !== undefined ? Math.round(volLevel * 100) : 0;
const volRow = document.createElement("div");
volRow.className = "device-item-volume";
volRow.dataset.entity = player.entity_id;
volRow.innerHTML = `
${ICONS.volumeHigh}
${volPct}% `;
const slider = volRow.querySelector("input");
const eid = player.entity_id;
slider.addEventListener("pointerdown", () => { this._deviceVolDragging.add(eid); });
slider.addEventListener("input", () => {
volRow.querySelector(".device-vol-pct").textContent = `${slider.value}%`;
});
slider.addEventListener("pointerup", () => {
if (!this._deviceVolDragging.has(eid)) return;
this._deviceVolDragging.delete(eid);
this._hass?.callService("media_player", "volume_set", {
entity_id: eid,
volume_level: parseInt(slider.value) / 100,
});
});
slider.addEventListener("pointercancel", () => { this._deviceVolDragging.delete(eid); });
frag.appendChild(volRow);
}
if (role === "available") {
item.addEventListener("click", (e) => {
if (e.target.closest(".attach")) return;
const prevActive = this._activePlayer;
this._activePlayer = player.entity_id;
this._savePlayer(player.entity_id);
if (this._activePlayer !== prevActive) {
this._groupMembers = [];
this._loadMAQueue();
this._loadGroupFromServer(this._activePlayer);
}
this._closeDeviceModal(card);
this._updatePlayerContent(card);
});
const attachBtn = item.querySelector(".attach");
if (attachBtn) {
attachBtn.addEventListener("click", (e) => {
e.stopPropagation();
this._attachPlayer(player.entity_id, card);
});
}
} else if (role === "member") {
const detachBtn = item.querySelector(".detach");
if (detachBtn) {
detachBtn.addEventListener("click", (e) => {
e.stopPropagation();
this._detachPlayer(player.entity_id, card);
});
}
}
return frag;
}
_attachPlayer(entityId, card) {
if (!this._hass || !this._activePlayer || entityId === this._activePlayer) return;
if (this._groupMembers.includes(entityId)) return;
const newMembers = [...this._groupMembers, entityId];
this._hass.callService("media_player", "join", {
entity_id: this._activePlayer,
group_members: newMembers,
});
this._groupMembers = newMembers;
this._saveGroupToServer();
this._openDeviceModal(card);
this._updateDeviceRow(card);
}
_detachPlayer(entityId, card) {
if (!this._hass) return;
const remainingMembers = this._groupMembers.filter(id => id !== entityId);
if (remainingMembers.length > 0) {
// Reduce the group by re-issuing join with the smaller list.
// This removes the detached player without calling unjoin on it directly,
// which is not supported by all media_player platforms.
this._hass.callService("media_player", "join", {
entity_id: this._activePlayer,
group_members: remainingMembers,
});
} else {
// Last member removed → dissolve the group by unjoining the master.
this._hass.callService("media_player", "unjoin", {
entity_id: this._activePlayer,
});
}
this._groupMembers = remainingMembers;
this._saveGroupToServer();
this._openDeviceModal(card);
this._updateDeviceRow(card);
}
/* ── Settings ── */
_openSettings(card) {
const modal = card.querySelector("#settings-modal");
const content = card.querySelector("#settings-content");
content.innerHTML = this._buildSettingsContent();
this._attachSettingsListeners(content, card);
modal.classList.add("open");
}
_closeSettings(card) {
card.querySelector("#settings-modal").classList.remove("open");
}
_buildSettingsContent() {
const debugBanner = this._debugMode ? `
${this._t("settings.debug_active")}
${this._t("settings.debug_hint")}
` : "";
if (this._maProviders.length === 0) {
return `${debugBanner}${this._t("settings.providers_empty")}
`;
}
const rows = this._maProviders.map(p => {
const key = p.instance_id || p.domain;
const enabled = this._enabledProviders === null || this._enabledProviders.has(key);
return `
${this._esc(p.name || p.domain)}
`;
}).join("");
return `
${debugBanner}
${this._t("settings.providers_title")}
${this._t("settings.providers_hint")}
${rows}`;
}
_attachSettingsListeners(content, card) {
content.querySelectorAll("input[data-provider]").forEach(input => {
input.addEventListener("change", () => {
const key = input.dataset.provider;
if (this._enabledProviders === null) {
this._enabledProviders = new Set(this._maProviders.map(p => p.instance_id || p.domain));
}
if (input.checked) {
this._enabledProviders.add(key);
} else {
this._enabledProviders.delete(key);
}
if (this._enabledProviders.size >= this._maProviders.length) {
this._enabledProviders = null;
}
this._savePref("mml_providers", this._enabledProviders ? JSON.stringify([...this._enabledProviders]) : "");
this._reloadLibrary();
});
});
}
_savePlayer(entityId) {
try { localStorage.setItem("mml_active_player", entityId); } catch (_) {}
}
_loadSavedPlayer() {
try { return localStorage.getItem("mml_active_player"); } catch (_) { return null; }
}
_savePref(key, value) {
try { localStorage.setItem(key, value); } catch (_) {}
}
_loadPref(key) {
try { return localStorage.getItem(key); } catch (_) { return null; }
}
_applyQueueVisibility(card) {
const qs = card.querySelector("#queue-section");
const btn = card.querySelector("#btn-queue-toggle");
const backdrop = card.querySelector("#queue-backdrop");
if (this._isMobile) {
if (qs) qs.classList.toggle("mml-queue-open", this._queueVisible);
if (backdrop) backdrop.classList.toggle("open", this._queueVisible);
} else {
if (qs) qs.style.display = this._queueVisible ? "" : "none";
}
if (btn) btn.classList.toggle("active", this._queueVisible);
}
_closeDeviceModal(card) {
card.querySelector("#device-modal").classList.remove("open");
}
_updateDeviceModalVolumes(card) {
const modal = card.querySelector("#device-modal");
if (!modal?.classList.contains("open")) return;
for (const volRow of modal.querySelectorAll(".device-item-volume")) {
const eid = volRow.dataset.entity;
const slider = volRow.querySelector("input");
if (!eid || !slider || this._deviceVolDragging.has(eid)) continue;
const st = this._hass?.states[eid];
const vol = st?.attributes?.volume_level;
if (vol === undefined) continue;
const pct = Math.round(vol * 100);
slider.value = pct;
const label = volRow.querySelector(".device-vol-pct");
if (label) label.textContent = `${pct}%`;
}
}
/* ── Search ── */
async _doSearch(card) {
if (!this._hass) return;
const id = ++this._searchId;
const query = this._searchQuery;
this._debugLog("Search start:", query, "id:", id);
this._searchLoading = true;
const resultsEl = card.querySelector("#search-results");
resultsEl.innerHTML = `
${this._t("search.searching")}
`;
let results = null;
{
const strategies = ["HA proxy", "HA proxy (library)"];
const candidates = [
this._searchViaHaProxy(query),
this._searchViaHaProxy(query, { libraryOnly: true }),
];
if (this._maEntryId) {
candidates.push(this._searchViaMaWs(query));
strategies.push("MA WS");
candidates.push(this._searchViaMaWs(query, { libraryOnly: true }));
strategies.push("MA WS (library)");
}
this._debugLog("Search strategies:", strategies.join(", "));
const settled = await Promise.allSettled(candidates);
const buckets = settled.map((s, i) => {
if (s.status === "fulfilled" && s.value) {
this._debugLog(`Search strategy ${strategies[i]}: has results`);
return s.value;
}
this._debugLog(`Search strategy ${strategies[i]}: ${s.status === "rejected" ? "rejected" : "null"}`);
return null;
});
for (const b of buckets) {
if (!b) continue;
results = results ? this._mergeSearchResults(results, b) : b;
}
}
if (!results) {
const browseEntity = this._getBrowseEntity();
if (browseEntity) {
this._debugLog("Search fallback: browse_media on", browseEntity);
results = await this._searchViaBrowseMedia(browseEntity, query);
}
}
if (!results) {
this._debugLog("Search: all strategies failed");
results = {
tracks: [], artists: [], albums: [], playlists: [],
error: this._t("search.unavailable"),
};
}
if (id !== this._searchId) return;
this._debugLog("Search results:", { tracks: results.tracks?.length, artists: results.artists?.length, albums: results.albums?.length, playlists: results.playlists?.length });
this._searchResults = results;
this._searchLoading = false;
this._renderSearchResults(card, this._searchResults);
}
/* Search via HA proxy endpoint /my_music_library/search
The HA backend calls MA server-side → no CORS, works from HTTPS. */
async _searchViaHaProxy(query, { libraryOnly = false } = {}) {
try {
const encodedQuery = encodeURIComponent(query);
const extra = libraryOnly ? "&library_only=true" : "";
const data = await this._callIntegration(
"GET",
`search?query=${encodedQuery}&limit=100${extra}`
);
if (!data) return null;
const results = this._parseMaWsSearchResults(data);
return results;
} catch (e) {
return null;
}
}
/* Search via music_assistant/search WebSocket command (registered by MA integration).
Passes through HA — no CORS, works from HTTPS. */
async _searchViaMaWs(query, { libraryOnly = false } = {}) {
const base = { entry_id: this._maEntryId, limit: 100 };
if (libraryOnly) base.library_only = true;
const attempts = [
{ type: "music_assistant/search", ...base, search_query: query },
{ type: "music_assistant/search", ...base, name: query },
{ type: "music_assistant/search_media_items", ...base, search_query: query },
];
for (const msg of attempts) {
try {
const result = await this._hass.callWS(msg);
if (result) {
return this._parseMaWsSearchResults(result);
}
} catch (e) {
// MA WS search attempt failed
}
}
return null;
}
/* Parse the result of music_assistant/search WebSocket command.
MA returns either a flat {tracks,artists,albums,playlists} or
a nested {results: {tracks,...}}. */
_parseMaWsSearchResults(data) {
const out = { tracks: [], artists: [], albums: [], playlists: [] };
const root = data?.results || data || {};
const map = { tracks: "track", artists: "artist", albums: "album", playlists: "playlist" };
for (const [key, type] of Object.entries(map)) {
const items = root[key] || [];
for (const item of items) {
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,
can_play: true,
});
}
}
// Return null if all buckets empty (probably wrong format, try next strategy)
const total = Object.values(out).reduce((s, a) => s + a.length, 0);
return total > 0 ? out : null;
}
_mergeSearchResults(a, b) {
const merged = { tracks: [], artists: [], albums: [], playlists: [] };
for (const key of Object.keys(merged)) {
const items = [...(a[key] || [])];
const seen = new Set(items.map(i => `${(i.id || "").toLowerCase()}|${(i.title || "").toLowerCase()}`));
for (const item of b[key] || []) {
const k = `${(item.id || "").toLowerCase()}|${(item.title || "").toLowerCase()}`;
if (!seen.has(k)) { seen.add(k); items.push(item); }
}
merged[key] = items;
}
return merged;
}
/* Search via HA browse_media WebSocket on an MA media_player entity */
async _searchViaBrowseMedia(entityId, query) {
const attempts = [
{ media_content_id: `music_assistant://search?query=${encodeURIComponent(query)}`, media_content_type: "" },
{ media_content_id: `music_assistant://search?query=${encodeURIComponent(query)}`, media_content_type: "search" },
];
for (const attempt of attempts) {
try {
const result = await this._hass.callWS({
type: "media_player/browse_media",
entity_id: entityId,
...attempt,
});
return this._parseSearchResults(result);
} catch (e) {
// browse_media search failed
}
}
return null;
}
_parseSearchResults(browseResult) {
const out = { tracks: [], artists: [], albums: [], playlists: [] };
if (!browseResult?.children) return out;
for (const child of browseResult.children) {
const type = (child.media_content_type || "").toLowerCase();
const item = {
id: child.media_content_id,
type,
title: child.title,
subtitle: child.media_artist || child.media_album_name || "",
thumbnail: child.thumbnail,
can_play: child.can_play,
can_expand: child.can_expand,
};
if (type === "track" || type === "music") out.tracks.push(item);
else if (type === "artist") out.artists.push(item);
else if (type === "album") out.albums.push(item);
else if (type === "playlist") out.playlists.push(item);
else out.tracks.push(item); // default bucket
}
return out;
}
_renderSearchResults(card, results) {
const el = card.querySelector("#search-results");
if (!results) {
el.innerHTML = `${ICONS.search}
${this._t("search.type_hint")}
`;
return;
}
if (results.error) {
el.innerHTML = `${ICONS.search}
${this._t("search.unavailable")}
${this._t("search.player_label")}: ${this._activePlayer || "—"}
${this._esc(results.error)}
${this._t("search.console_hint")}
`;
return;
}
const searchTab = this._resolvedTabs.find(t => t.type === "search");
const useColumns = searchTab?.search_layout === "columns";
const INITIAL_CARDS = 15;
const INITIAL_LIST = 20;
const LAZY_BATCH = 30;
const sections = [
{ key: "artists", label: this._t("search.artists"), icon: "artist", card: true },
{ key: "albums", label: this._t("search.albums"), icon: "album", card: true },
{ key: "tracks", label: this._t("search.tracks"), icon: "music", card: false },
{ key: "playlists", label: this._t("search.playlists"), icon: "playlist", card: true },
];
const populated = sections.filter(s => (results[s.key] || []).length > 0);
if (populated.length === 0) {
el.innerHTML = `${ICONS.search}
${this._t("search.no_results")} « ${this._searchQuery} »
`;
return;
}
el.innerHTML = "";
if (useColumns) {
const wrapper = document.createElement("div");
wrapper.className = "search-columns";
for (const { key, label, icon: iconName } of populated) {
const items = results[key] || [];
const col = document.createElement("div");
col.className = "search-column";
col.innerHTML = `${label}
`
+ items.map(i => this._renderResultItem(i, iconName)).join("");
wrapper.appendChild(col);
}
el.appendChild(wrapper);
this._attachItemActions(el);
} else {
const lazyState = [];
for (const { key, label, icon: iconName, card: isCard } of populated) {
const allItems = results[key] || [];
const initial = isCard ? INITIAL_CARDS : INITIAL_LIST;
const shown = allItems.slice(0, initial);
const remaining = allItems.slice(initial);
const secEl = document.createElement("div");
secEl.className = "search-section";
if (isCard) {
secEl.innerHTML = `${label}
${shown.map(i => this._renderSearchCard(i, iconName)).join("")}
`;
} else {
secEl.innerHTML = `${label}
`
+ shown.map(i => this._renderResultItem(i, iconName)).join("");
}
el.appendChild(secEl);
if (remaining.length > 0) {
lazyState.push({ el: secEl, items: remaining, iconName, isCard, loaded: 0 });
}
}
this._attachItemActions(el);
if (lazyState.length > 0) {
const scrollContainer = el.closest(".tab-panel") || el.parentElement;
const onScroll = () => {
let allDone = true;
for (const st of lazyState) {
if (st.loaded >= st.items.length) continue;
const rect = st.el.getBoundingClientRect();
const containerRect = scrollContainer.getBoundingClientRect();
if (rect.bottom < containerRect.bottom + 200) {
const batch = st.items.slice(st.loaded, st.loaded + LAZY_BATCH);
st.loaded += batch.length;
if (st.isCard) {
const scroll = st.el.querySelector(".lib-scroll");
if (scroll) scroll.insertAdjacentHTML("beforeend", batch.map(i => this._renderSearchCard(i, st.iconName)).join(""));
} else {
st.el.insertAdjacentHTML("beforeend", batch.map(i => this._renderResultItem(i, st.iconName)).join(""));
}
this._attachItemActions(st.el);
}
if (st.loaded < st.items.length) allDone = false;
}
if (allDone) scrollContainer.removeEventListener("scroll", onScroll);
};
scrollContainer.addEventListener("scroll", onScroll, { passive: true });
}
}
}
_renderResultItem(item, iconName) {
const thumb = item.thumbnail
? ` `
: `${ICONS[iconName] || ICONS.music}
`;
const queueType = item.type === "track" || iconName === "music" ? "track" : item.type;
const canQueue = ["track", "music", "album", "playlist", "artist"].includes(queueType);
return `
${thumb}
${this._esc(item.title)}
${item.subtitle ? `
${this._esc(item.subtitle)}
` : ""}
${canQueue ? `
${ICONS.plus} ` : ""}
${item.can_play ? `
${ICONS.play} ` : ""}
`;
}
/* ── Library ── */
_getLibTabState(tabId) {
const id = tabId || this._tab;
if (!this._libTabState[id]) {
this._libTabState[id] = {
source: this._loadPref(`mml_lib_source_${id}`) || this._loadPref("mml_lib_source") || "all",
fav: this._loadPref(`mml_lib_fav_${id}`) === "true",
browse: false,
browseStack: [],
};
}
return this._libTabState[id];
}
get _libSourceFilter() { return this._getLibTabState()?.source || "all"; }
set _libSourceFilter(v) { this._getLibTabState().source = v; }
get _libFavFilter() { return this._getLibTabState()?.fav || false; }
set _libFavFilter(v) { this._getLibTabState().fav = v; }
get _libBrowseMode() { return this._getLibTabState()?.browse || false; }
set _libBrowseMode(v) { this._getLibTabState().browse = v; }
get _browseStack() { return this._getLibTabState()?.browseStack || []; }
set _browseStack(v) { this._getLibTabState().browseStack = v; }
static _LOCAL_PROVIDERS = ["filesystem_local", "filesystem_smb", "filesystem_nfs", "plex"];
_isLocalProvider(domain) {
return MyMusicLibraryCard._LOCAL_PROVIDERS.some(p => domain.startsWith(p));
}
_filterLibItems(items) {
let result = items;
const _itemUri = (item) => item.media_content_id || item.uri || "";
if (this._libSourceFilter !== "all") {
result = result.filter(item => {
const provs = item.providers || [];
if (provs.length === 0) {
const uri = _itemUri(item);
const scheme = uri.includes("://") ? uri.split("://")[0] : "";
if (this._libSourceFilter === "local") return this._isLocalProvider(scheme);
return scheme && !this._isLocalProvider(scheme);
}
if (this._libSourceFilter === "local") return provs.some(p => this._isLocalProvider(p));
return provs.some(p => !this._isLocalProvider(p));
});
}
if (this._enabledProviders !== null && this._enabledProviders.size > 0) {
const _matchesAnyProvider = (key) => {
if (this._enabledProviders.has(key)) return true;
return [...this._enabledProviders].some(ep => ep.startsWith(key + "_") || ep === key);
};
const before = result.length;
result = result.filter(item => {
const keys = (item.provider_instances?.length ? item.provider_instances : item.providers) || [];
const filtered = keys.filter(k => k !== "builtin" && k !== "library");
if (filtered.length === 0) {
const scheme = _itemUri(item).split("://")[0];
if (!scheme || scheme === "builtin" || scheme === "library") return true;
return _matchesAnyProvider(scheme);
}
return filtered.some(k => this._enabledProviders.has(k) || _matchesAnyProvider(k));
});
this._debugLog("Provider filter:", before, "→", result.length,
"| enabled:", [...this._enabledProviders]);
}
result.sort((a, b) => (a.title || "").localeCompare(b.title || "", undefined, { sensitivity: "base" }));
return result;
}
/* Returns list of enabled provider instance_ids when filtering is active, null otherwise. */
_activeProviderFilter() {
if (this._enabledProviders === null || this._enabledProviders.size === 0) return null;
if (this._enabledProviders.size >= this._maProviders.length) return null;
return [...this._enabledProviders];
}
/* Fetch library items for a section, querying per-provider when filter is active.
Returns deduplicated items array. */
async _fetchLibraryFiltered(type, limit, offset, favorite, providers) {
if (!providers || providers.length === 0) {
const r = await this._callIntegration("GET",
`library?type=${type}&limit=${limit}&offset=${offset}&favorite=${favorite}`);
return r?.items || [];
}
if (providers.length === 1) {
const r = await this._callIntegration("GET",
`library?type=${type}&limit=${limit}&offset=${offset}&favorite=${favorite}&provider=${encodeURIComponent(providers[0])}`);
return r?.items || [];
}
const results = await Promise.all(providers.map(p =>
this._callIntegration("GET",
`library?type=${type}&limit=200&offset=0&favorite=${favorite}&provider=${encodeURIComponent(p)}`)
));
const seen = new Set();
const merged = [];
for (const r of results) {
for (const item of (r?.items || [])) {
const key = item.media_content_id || item.title;
if (!seen.has(key)) { seen.add(key); merged.push(item); }
}
}
return merged;
}
_resolveLibLayout() {
const libTab = this._resolvedTabs.find(t => t.id === this._tab && t.type === "library")
|| this._resolvedTabs.find(t => t.type === "library");
const configLayout = libTab?.layout || "lanes";
const sectionCount = (libTab?.sections || []).length;
if (configLayout === "grid" && sectionCount > 1) return "lanes";
if (configLayout !== "auto") return configLayout;
if (sectionCount <= 1) return "grid";
const isDesktop = window.matchMedia("(min-width: 640px) and (hover: hover)").matches;
if (sectionCount <= 2) return isDesktop ? "columns" : "lanes";
return "lanes";
}
_reloadLibrary() {
this._libLoadedTabs.clear();
const card = this.shadowRoot.querySelector(".card-root");
if (card) {
const panel = card.querySelector(`.tab-panel[data-panel="${this._tab}"]`);
if (panel) {
panel.querySelectorAll(".lib-filter-btn").forEach(b =>
b.classList.toggle("active", b.dataset.source === this._libSourceFilter));
const toggle = panel.querySelector(".browse-mode-toggle");
if (toggle) {
toggle.style.display = this._libSourceFilter === "local" ? "" : "none";
toggle.querySelectorAll(".browse-mode-btn").forEach(b =>
b.classList.toggle("active", (b.dataset.browse === "true") === this._libBrowseMode));
}
const favBtn = panel.querySelector(".lib-filter-fav");
if (favBtn) favBtn.style.display = this._libBrowseMode ? "none" : "";
}
}
this._loadLibrary();
}
async _loadLibrary() {
if (!this._hass) return;
const card = this.shadowRoot.querySelector(".card-root");
const activePanel = card?.querySelector(`.tab-panel[data-panel="${this._tab}"]`);
const libEl = (activePanel || card)?.querySelector("#lib-content-inner");
if (!libEl) return;
this._libLoadedTabs.add(this._tab);
this._debugLog("Library load start, browseMode:", this._libBrowseMode, "sourceFilter:", this._libSourceFilter, "favFilter:", this._libFavFilter);
if (this._libBrowseMode) {
const currentUri = this._browseStack.length ? this._browseStack[this._browseStack.length - 1].uri : null;
return this._loadBrowse(currentUri, libEl);
}
this._libLoadId = (this._libLoadId || 0) + 1;
const loadId = this._libLoadId;
const favorite = this._libFavFilter;
const sourceFilter = this._libSourceFilter;
const activeProviders = this._activeProviderFilter();
const SECTION_META = {
artists: { icon: "artist" },
albums: { icon: "album" },
playlists: { icon: "playlist" },
tracks: { icon: "music" },
radios: { icon: "radio" },
recently_played: { icon: "history" },
recently_added: { icon: "newBox" },
recommended: { icon: "sparkle" },
flows: { icon: "wave" },
};
const DISCOVER_SECTIONS = ["recently_played", "recently_added", "recommended", "flows"];
const DISCOVER_FOLDER_MAP = {
recently_played: ["recently_played"],
recently_added: ["recently_added_tracks", "recently_added_albums"],
flows: null,
recommended: null,
};
const libTab = this._resolvedTabs.find(t => t.id === this._tab && t.type === "library")
|| this._resolvedTabs.find(t => t.type === "library");
const sectionKeys = libTab?.sections || ["artists", "albums", "playlists", "tracks"];
const SECTIONS = sectionKeys.map(key => ({
type: key,
label: this._t(`lib.${key}`),
icon: SECTION_META[key]?.icon || "music",
favorite,
}));
const PAGE = 25;
const MAX_PAGES = 8;
const layout = this._resolveLibLayout();
this._activeLibLayout = layout;
libEl.innerHTML = `
${this._t("lib.loading")}
`;
libEl.classList.toggle("lib-layout-columns", layout === "columns");
this._libSections = {};
const allDiscover = sectionKeys.every(k => DISCOVER_SECTIONS.includes(k));
const filterBar = (activePanel || card)?.querySelector(".lib-filters");
if (filterBar) {
filterBar.style.display = allDiscover ? "none" : "";
}
// Helper: resolve per-item icon for mixed-type discover sections
const _itemIcon = (item) => {
const t = (item.media_content_type || "").toLowerCase();
if (t.includes("artist")) return "artist";
if (t.includes("album")) return "album";
if (t.includes("playlist")) return "playlist";
if (t.includes("radio")) return "radio";
if (t === "track") return "music";
return "music";
};
// Helper: build section HTML (layout-aware)
const isDiscoverSection = (type) => DISCOVER_SECTIONS.includes(type);
const sectionHtml = (type, label, iconName, items) => {
const isTrackList = iconName === "music";
const itemsHtml = isTrackList
? items.map(i => this._renderLibListItem(i)).join("")
: isDiscoverSection(type)
? items.map(i => this._renderLibCard(i, _itemIcon(i))).join("")
: items.map(i => this._renderLibCard(i, iconName)).join("");
const sentinel = isTrackList
? `
`
: `
`;
let containerClass, containerId;
if (isTrackList) {
containerId = `lib-list-${type}`;
containerClass = "";
} else if (layout === "grid" || layout === "columns") {
containerId = `lib-grid-${type}`;
containerClass = "lib-grid";
} else {
containerId = `lib-scroll-${type}`;
containerClass = "lib-scroll";
}
const inner = `${itemsHtml}${sentinel}
`;
const body = (layout === "lanes" && !isTrackList)
? `
${ICONS.chevronLeft}
${inner}
${ICONS.chevronRight}
`
: inner;
return `
${body}
`;
};
// Helper: append a section into its pre-created placeholder
const appendSection = (type, label, iconName, items) => {
if (loadId !== this._libLoadId) return; // stale load
const loader = libEl.querySelector("#lib-loader");
if (loader) loader.remove();
const placeholder = libEl.querySelector(`#lib-slot-${type}`);
if (!placeholder) return;
placeholder.innerHTML = sectionHtml(type, label, iconName, items);
const sec = placeholder.querySelector(`#lib-sec-${type}`);
if (sec) this._attachItemActions(sec);
if (layout === "lanes") this._attachLaneArrows(sec);
};
// Lazy-load recommendations: fetched once on first discover section, shared by all
let _recPromise = null;
const _getRecommendations = () => {
if (_recPromise) return _recPromise;
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 8000));
const fetcher = this._callIntegration("GET", "recommendations").then(d => d?.folders || []);
_recPromise = Promise.race([fetcher, timeout]).catch(err => {
this._debugLog("Recommendations fetch failed:", err);
return [];
});
return _recPromise;
};
const _isProviderFolder = (f) => {
const p = f.provider_instance || f.provider_domain || "";
return p && p !== "library" && p !== "builtin";
};
const _matchFolderProvider = (key) => {
if (!key || key === "library" || key === "builtin") return true;
if (this._enabledProviders.has(key)) return true;
return [...this._enabledProviders].some(ep => ep.startsWith(key + "_") || key.startsWith(ep.split("--")[0] + "--") || ep === key);
};
const _isFolderEnabled = (f) => {
if (!_isProviderFolder(f)) return true;
if (this._enabledProviders === null) return true;
const inst = f.provider_instance || f.provider_domain || "";
return _matchFolderProvider(inst);
};
const _extractDiscoverItems = async (sectionType) => {
const recFolders = await _getRecommendations();
if (!recFolders || recFolders.length === 0) return [];
const folders = recFolders.filter(_isFolderEnabled);
const mapping = DISCOVER_FOLDER_MAP[sectionType];
if (Array.isArray(mapping)) {
return folders
.filter(f => mapping.includes(f.folder_id))
.flatMap(f => f.items || []);
}
if (sectionType === "flows") {
return folders
.filter(f => {
const id = (f.folder_id || "").toLowerCase();
const name = (f.name || "").toLowerCase();
return id.includes("flow") || name.includes("flow");
})
.flatMap(f => f.items || []);
}
const claimedIds = new Set();
for (const [key, val] of Object.entries(DISCOVER_FOLDER_MAP)) {
if (key === "recommended") continue;
if (Array.isArray(val)) val.forEach(id => claimedIds.add(id));
}
return folders
.filter(f => {
if (claimedIds.has(f.folder_id)) return false;
const id = (f.folder_id || "").toLowerCase();
const name = (f.name || "").toLowerCase();
if (id.includes("flow") || name.includes("flow")) return false;
if (["random_artists", "random_albums", "recent_favorite_tracks", "favorite_playlists", "favorite_radio", "in_progress"].includes(f.folder_id)) return false;
return true;
})
.flatMap(f => f.items || []);
};
// Fetch one section: first page in parallel, then lazy-paginate if source filter needs more
const multiProvider = activeProviders && activeProviders.length > 1;
const fetchSection = async (s) => {
// Discover sections fetch recommendations lazily (with timeout, non-blocking for other sections)
if (DISCOVER_SECTIONS.includes(s.type)) {
try {
const raw = await _extractDiscoverItems(s.type);
if (loadId !== this._libLoadId) return;
const items = this._filterLibItems(raw);
if (items.length > 0) {
this._libSections[s.type] = { offset: items.length, loading: false, exhausted: true, favorite: false, iconName: s.icon };
appendSection(s.type, s.label, s.icon, items);
} else {
this._libSections[s.type] = { offset: 0, loading: false, exhausted: true, favorite: false, iconName: s.icon };
}
} catch (err) {
this._debugLog("Discover section failed:", s.type, err);
this._libSections[s.type] = { offset: 0, loading: false, exhausted: true, favorite: false, iconName: s.icon };
}
return;
}
let offset = 0;
let exhausted = false;
let pages = 0;
let rendered = false;
while (pages < MAX_PAGES && !exhausted) {
if (loadId !== this._libLoadId) return; // stale load
pages++;
const raw = await this._fetchLibraryFiltered(s.type, PAGE, offset, s.favorite, activeProviders);
offset += raw.length;
if (raw.length < PAGE || multiProvider) exhausted = true;
const filtered = this._filterLibItems(raw);
const hasClientFilter = sourceFilter !== "all";
if (filtered.length > 0 && !rendered) {
this._libSections[s.type] = { offset, loading: false, exhausted, favorite: s.favorite, iconName: s.icon };
appendSection(s.type, s.label, s.icon, filtered);
rendered = true;
if (!hasClientFilter || exhausted) break;
continue;
}
if (filtered.length > 0 && rendered) {
this._libSections[s.type].offset = offset;
this._libSections[s.type].exhausted = exhausted;
const isTrackList = s.icon === "music";
const sentinel = libEl.querySelector(`#lib-sentinel-${s.type}`);
const container = isTrackList
? libEl.querySelector(`#lib-list-${s.type}`)
: (libEl.querySelector(`#lib-scroll-${s.type}`) || libEl.querySelector(`#lib-grid-${s.type}`));
if (container && sentinel) {
const tmp = document.createElement("div");
tmp.innerHTML = filtered.map(i => isTrackList ? this._renderLibListItem(i) : this._renderLibCard(i, s.icon)).join("");
while (tmp.firstChild) container.insertBefore(tmp.firstChild, sentinel);
this._attachItemActions(container);
}
break;
}
if (!hasClientFilter || exhausted) break;
}
if (!this._libSections[s.type]) {
this._libSections[s.type] = { offset, loading: false, exhausted: true, favorite: s.favorite, iconName: s.icon };
}
};
// Pre-create ordered placeholders so parallel fetches render in config order
if (layout === "columns") {
const colsHtml = SECTIONS.map(s =>
`
`
).join("");
const colsWrap = document.createElement("div");
colsWrap.className = "lib-columns-wrap";
colsWrap.innerHTML = colsHtml;
libEl.appendChild(colsWrap);
} else {
for (const s of SECTIONS) {
const slot = document.createElement("div");
slot.id = `lib-slot-${s.type}`;
libEl.appendChild(slot);
}
}
// Launch all sections in parallel — each renders itself as soon as ready
await Promise.allSettled(SECTIONS.map(s => fetchSection(s)));
if (loadId !== this._libLoadId) return;
// Clean up loader if still present (all sections empty)
const loader = libEl.querySelector("#lib-loader");
if (loader) loader.remove();
if (!libEl.querySelector(".lib-section")) {
libEl.innerHTML = `${ICONS.library}
${this._t("lib.empty")}
${this._t("lib.empty_hint")}
`;
}
this._attachLibInfiniteScroll(libEl);
}
_attachLibInfiniteScroll(libEl) {
const layout = this._activeLibLayout || "lanes";
for (const [type, state] of Object.entries(this._libSections)) {
if (state.iconName === "music") continue;
if (layout === "lanes") {
const scrollEl = libEl.querySelector(`#lib-scroll-${type}`);
if (!scrollEl) continue;
scrollEl.addEventListener("scroll", () => {
const remaining = scrollEl.scrollWidth - scrollEl.scrollLeft - scrollEl.clientWidth;
if (remaining < 300 && !state.loading && !state.exhausted) {
this._loadMoreLibSection(type, libEl);
}
}, { passive: true });
}
if (layout === "grid" || layout === "columns") {
const scrollParent = layout === "columns"
? libEl.querySelector(`#lib-slot-${type}`)
: libEl;
if (!scrollParent) continue;
scrollParent.addEventListener("scroll", () => {
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight;
if (remaining < 300 && !state.loading && !state.exhausted) {
this._loadMoreLibSection(type, libEl);
}
}, { passive: true });
}
}
const tracksState = this._libSections["tracks"];
if (tracksState) {
const tracksScrollParent = (layout === "columns")
? (libEl.querySelector("#lib-slot-tracks") || libEl)
: libEl;
tracksScrollParent.addEventListener("scroll", () => {
const remaining = tracksScrollParent.scrollHeight - tracksScrollParent.scrollTop - tracksScrollParent.clientHeight;
if (remaining < 300 && !tracksState.loading && !tracksState.exhausted) {
this._loadMoreLibSection("tracks", libEl);
}
}, { passive: true });
}
}
_attachLaneArrows(sectionEl) {
if (!sectionEl) return;
const wrap = sectionEl.querySelector(".lib-lane-wrap");
if (!wrap) return;
const scrollEl = wrap.querySelector(".lib-scroll");
if (!scrollEl) return;
const leftBtn = wrap.querySelector(".lib-lane-arrow.left");
const rightBtn = wrap.querySelector(".lib-lane-arrow.right");
const updateArrows = () => {
const atStart = scrollEl.scrollLeft <= 4;
const atEnd = scrollEl.scrollLeft + scrollEl.clientWidth >= scrollEl.scrollWidth - 4;
leftBtn.classList.toggle("visible", !atStart);
rightBtn.classList.toggle("visible", !atEnd);
};
scrollEl.addEventListener("scroll", updateArrows, { passive: true });
requestAnimationFrame(updateArrows);
const scrollBy = (dir) => {
const amount = scrollEl.clientWidth * 0.75;
scrollEl.scrollBy({ left: dir === "left" ? -amount : amount, behavior: "smooth" });
};
leftBtn.addEventListener("click", (e) => { e.stopPropagation(); scrollBy("left"); });
rightBtn.addEventListener("click", (e) => { e.stopPropagation(); scrollBy("right"); });
}
async _loadMoreLibSection(type, libEl) {
const state = this._libSections[type];
if (!state || state.loading || state.exhausted) return;
state.loading = true;
const { favorite, iconName } = state;
const isTrackList = iconName === "music";
const PAGE = 25;
const MAX_PAGES = 8;
const sourceFilter = this._libSourceFilter;
const activeProviders = this._activeProviderFilter();
const appendItems = (items) => {
const sentinel = libEl.querySelector(`#lib-sentinel-${type}`);
const container = isTrackList
? libEl.querySelector(`#lib-list-${type}`)
: (libEl.querySelector(`#lib-scroll-${type}`) || libEl.querySelector(`#lib-grid-${type}`));
if (!container || !sentinel) return;
const tmp = document.createElement("div");
tmp.innerHTML = items.map(i => isTrackList ? this._renderLibListItem(i) : this._renderLibCard(i, iconName)).join("");
while (tmp.firstChild) container.insertBefore(tmp.firstChild, sentinel);
this._attachItemActions(container);
};
try {
let pages = 0;
while (pages < MAX_PAGES && !state.exhausted) {
pages++;
const multiProvider = activeProviders && activeProviders.length > 1;
const rawItems = await this._fetchLibraryFiltered(type, PAGE, state.offset, favorite, activeProviders);
if (rawItems.length < PAGE || multiProvider) state.exhausted = true;
state.offset += rawItems.length;
const filtered = this._filterLibItems(rawItems);
if (filtered.length > 0) {
appendItems(filtered);
break;
}
const hasClientFilter = sourceFilter !== "all";
if (!hasClientFilter || state.exhausted) break;
}
} catch (err) {
state.exhausted = true;
} finally {
state.loading = false;
}
}
_renderSearchCard(item, iconName) {
const round = iconName === "artist";
const artClass = `search-card-art${round ? " round" : ""}`;
const placeholderClass = `search-card-art-placeholder${round ? " round" : ""}`;
const art = item.thumbnail
? ` `
: `${ICONS[iconName] || ICONS.music}
`;
const type = item.type || iconName;
const action = type === "artist" ? "browse" : (type === "track" ? "play" : "play-queue");
const extra = type === "artist" ? `data-title="${this._esc(item.title)}" data-thumb="${this._esc(this._resolveImageUrl(item.thumbnail) || "")}"` : "";
const canQueue = ["album", "playlist", "track", "artist"].includes(type);
return `
${art}
${this._esc(item.title)}
${item.subtitle ? `
${this._esc(item.subtitle)}
` : ""}
${canQueue ? `
${ICONS.plus} ` : ""}
`;
}
_attachItemActions(container) {
container.querySelectorAll("[data-action]").forEach(el => {
el.addEventListener("click", (e) => {
if (e.target.closest(".add-queue-btn")) return;
e.stopPropagation();
const { action, id, type, title, thumb } = el.dataset;
if (action === "browse") {
this._openArtistPage(id, title || id, thumb || "");
} else if (action === "play-queue") {
this._playAndSwitchToPlayer(id, type);
} else {
this._playItem(id, type);
const card = this.shadowRoot.querySelector(".card-root");
if (card) this._setActiveTab("player", card);
}
});
});
container.querySelectorAll(".add-queue-btn").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
const { queueId, queueType } = btn.dataset;
this._showQueueDropdown(btn, queueId, queueType);
});
});
}
_openArtistPage(id, title, thumbnail) {
const card = this.shadowRoot.querySelector(".card-root");
if (!card) return;
const searchMain = card.querySelector("#search-main");
const artistPanel = card.querySelector("#artist-page");
if (!searchMain || !artistPanel) return;
// Switch to search tab if not already there
this._setActiveTab("search", card);
searchMain.style.display = "none";
artistPanel.style.display = "";
const heroArt = thumbnail
? ` `
: `${ICONS.artist}
`;
artistPanel.innerHTML = `
${this._t("lib.loading_short")}
`;
artistPanel.querySelector("#artist-back").addEventListener("click", () => {
artistPanel.style.display = "none";
searchMain.style.display = "";
});
artistPanel.querySelector("#artist-queue-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
this._showQueueDropdown(e.currentTarget, id, "artist");
});
this._callIntegration("GET", `subitems?action=artist_albums&uri=${encodeURIComponent(id)}&limit=100`)
.then(r => {
const items = r?.items || [];
const groups = {};
for (const item of items) {
const t = (item.album_type || "album").toLowerCase();
(groups[t] = groups[t] || []).push(item);
}
const labelMap = {
album: this._t("lib.album_types.album"),
ep: this._t("lib.album_types.ep"),
single: this._t("lib.album_types.single"),
compilation: this._t("lib.album_types.compilation"),
};
const order = ["album", "ep", "single", "compilation"];
let sectionsHtml = "";
for (const t of order) {
const group = groups[t];
if (!group?.length) continue;
sectionsHtml += `
${labelMap[t] || t}
${group.map(i => this._renderSearchCard({ id: i.media_content_id, type: "album", title: i.title, subtitle: i.media_artist, thumbnail: i.thumbnail }, "album")).join("")}
`;
}
if (!sectionsHtml) {
sectionsHtml = `${ICONS.library}
${this._t("lib.no_albums")}
`;
}
const sections = artistPanel.querySelector(".artist-page-sections");
if (sections) {
sections.innerHTML = sectionsHtml;
this._attachItemActions(sections);
}
})
.catch(err => {
// Artist albums fetch failed
const sections = artistPanel.querySelector(".artist-page-sections");
if (sections) sections.innerHTML = `${ICONS.library}
${this._t("lib.load_error")}
`;
});
}
async _playAndSwitchToPlayer(id, type) {
if (!this._hass || !this._activePlayer) return;
const card = this.shadowRoot.querySelector(".card-root");
if (!card) return;
this._setActiveTab("player", card);
this._debugLog("PlayQueue:", id, "type:", type, "on:", this._activePlayer);
try {
await this._callServiceSilent("music_assistant", "play_media", {
entity_id: this._activePlayer,
media_id: id,
enqueue: "replace",
});
} catch (_) {
try {
await this._callServiceSilent("media_player", "play_media", {
entity_id: this._activePlayer,
media_content_id: id,
media_content_type: type || "music",
});
} catch (err) {
this._debugLog("play_media error:", err);
if (this._isMediaNotFoundError(err)) {
this._showToast(this._t("errors.media_not_found"));
}
}
}
this._refreshQueueSoon(1500);
}
_updateQueueDisplay(card) {
const section = card.querySelector("#queue-section");
if (!section) return;
const list = section.querySelector("#queue-list");
if (!this._maQueueItems.length) {
list.innerHTML = `${this._t("queue.empty")}
`;
return;
}
list.innerHTML = this._maQueueItems.map((t, i) => `
${i + 1}
${this._esc(t.title)}
${t.media_artist ? `
${this._esc(t.media_artist)}
` : ""}
${t.duration ? `
${fmt(t.duration)}
` : ""}
${ICONS.remove}
`).join("");
list.querySelectorAll(".queue-item").forEach(item => {
item.addEventListener("click", (e) => {
if (e.target.closest(".queue-remove")) return;
this._jumpToQueueIndex(parseInt(item.dataset.queueIdx, 10));
});
});
list.querySelectorAll(".queue-remove").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
this._removeFromQueue(btn.dataset.queueItemId);
});
});
}
async _removeFromQueue(queueItemId) {
if (!queueItemId || !this._activePlayer) return;
try {
await this._callIntegration("POST", "ma_queue", {
player: this._activePlayer,
action: "delete_item",
item_id: queueItemId,
});
this._refreshQueueSoon(500);
} catch (err) {
this._debugLog("removeFromQueue failed:", err);
}
}
async _addItemToQueue(id, type, mode) {
if (!this._hass || !this._activePlayer) return;
const enqueue = mode === "next" ? "next" : "add";
try {
await this._callServiceSilent("music_assistant", "play_media", {
entity_id: this._activePlayer,
media_id: id,
enqueue,
});
this._showToast(this._t(mode === "next" ? "queue.added_next" : "queue.added_end"));
this._refreshQueueSoon(1000);
} catch (err) {
this._debugLog("addItemToQueue failed:", err);
}
}
_showQueueDropdown(btnEl, id, type) {
this._closeQueueDropdown();
const dd = document.createElement("div");
dd.className = "queue-dropdown";
dd.id = "mml-queue-dropdown";
dd.innerHTML = `
${this._t("queue.play_next")}
${this._t("queue.add_to_end")}
${ICONS.shuffle} ${this._t("queue.start_mix")}
`;
dd.querySelectorAll(".queue-dropdown-item").forEach(item => {
item.addEventListener("click", (e) => {
e.stopPropagation();
if (item.dataset.mode === "radio") {
this._startRadioMode(id, type);
} else {
this._addItemToQueue(id, type, item.dataset.mode);
}
this._closeQueueDropdown();
});
});
const root = this.shadowRoot;
root.appendChild(dd);
const rect = btnEl.getBoundingClientRect();
const hostRect = this.getBoundingClientRect();
dd.style.top = `${rect.bottom - hostRect.top}px`;
dd.style.left = `${Math.max(0, rect.right - hostRect.left - 200)}px`;
const autoClose = (e) => {
const path = e.composedPath();
if (!path.includes(dd) && !path.includes(btnEl)) {
this._closeQueueDropdown();
}
};
setTimeout(() => {
document.addEventListener("click", autoClose, true);
root.addEventListener("click", autoClose, true);
}, 0);
this._queueDropdownCleanup = () => {
document.removeEventListener("click", autoClose, true);
root.removeEventListener("click", autoClose, true);
};
}
_closeQueueDropdown() {
const existing = this.shadowRoot?.querySelector("#mml-queue-dropdown");
if (existing) existing.remove();
if (this._queueDropdownCleanup) { this._queueDropdownCleanup(); this._queueDropdownCleanup = null; }
}
async _startRadioMode(id, type) {
if (!this._hass || !this._activePlayer) return;
try {
await this._callServiceSilent("music_assistant", "play_media", {
entity_id: this._activePlayer,
media_id: id,
enqueue: "replace",
radio_mode: true,
});
this._showToast(this._t("queue.mix_started"));
const card = this.shadowRoot.querySelector(".card-root");
if (card) this._setActiveTab("player", card);
this._refreshQueueSoon(1500);
} catch (err) {
this._debugLog("startRadioMode failed:", err);
}
}
async _jumpToQueueIndex(index) {
if (!this._hass || !this._activePlayer) return;
if (index < 0 || index >= this._maQueueItems.length) return;
try {
await this._callIntegration("POST", "queue_jump", {
player: this._activePlayer,
index,
});
} catch (err) {
this._debugLog("queue jump failed:", err);
}
}
/* ── Browse mode ── */
_buildBrowseNav() {
// Breadcrumb always rendered — present in all states (success, empty, error)
const crumbs = [{ uri: null, label: this._t("lib.browse_root") }, ...this._browseStack];
const crumbHtml = crumbs.map((c, i) => {
const isLast = i === crumbs.length - 1;
const sep = i > 0 ? `› ` : "";
return `${sep}${this._esc(c.label)} `;
}).join("");
return `${crumbHtml}
`;
}
_attachBrowseNav(libEl) {
libEl.querySelector("#browse-crumbs")?.addEventListener("click", (e) => {
const btn = e.target.closest(".browse-crumb");
if (!btn || btn.classList.contains("current")) return;
const idx = parseInt(btn.dataset.crumbIdx, 10);
// idx 0 = root → empty stack; idx N → keep first N-1 entries
this._browseStack = idx === 0 ? [] : this._browseStack.slice(0, idx);
this._libLoadedTabs.delete(this._tab);
this._loadLibrary();
});
}
async _loadBrowse(uri, libEl) {
if (!libEl) {
const card = this.shadowRoot.querySelector(".card-root");
const activePanel = card?.querySelector(`.tab-panel[data-panel="${this._tab}"]`);
libEl = (activePanel || card)?.querySelector("#lib-content-inner");
}
if (!libEl) return;
libEl.innerHTML = `
${this._buildBrowseNav()}
${this._t("lib.loading_short")}
`;
this._attachBrowseNav(libEl);
try {
const path = uri ? `browse?uri=${encodeURIComponent(uri)}` : "browse";
const data = await this._callIntegration("GET", path);
const items = data?.items || [];
libEl.innerHTML = `
${this._buildBrowseNav()}
${items.length
? items.map(item => this._renderBrowseItem(item)).join("")
: `
${ICONS.folder}
${this._t("lib.browse_empty")}
`
}
`;
this._attachBrowseNav(libEl);
// Browse item actions
libEl.querySelector("#browse-list")?.addEventListener("click", (e) => {
const playBtn = e.target.closest(".browse-play-btn");
const row = e.target.closest(".browse-item");
if (!row) return;
const { uri: itemUri, type, folder, title } = row.dataset;
if (playBtn) {
e.stopPropagation();
this._playItem(itemUri, type || "music");
const card = this.shadowRoot.querySelector(".card-root");
if (card) this._setActiveTab("player", card);
return;
}
if (row.dataset.back === "true") {
// MA virtual back item — navigate up one level
if (this._browseStack.length > 0) this._browseStack.pop();
this._libLoadedTabs.delete(this._tab);
this._loadLibrary();
} else if (folder === "true") {
this._browseStack.push({ uri: itemUri, label: title });
this._libLoadedTabs.delete(this._tab);
this._loadLibrary();
} else {
this._playItem(itemUri, type || "music");
const card = this.shadowRoot.querySelector(".card-root");
if (card) this._setActiveTab("player", card);
}
});
} catch (err) {
libEl.innerHTML = `
${this._buildBrowseNav()}
${ICONS.library}
${this._t("lib.browse_error")}
`;
this._attachBrowseNav(libEl);
}
}
_renderBrowseItem(item) {
if (item.is_back) {
// MA virtual back item — render as a simple "go up" row, no play button
return `
`;
}
const isFolder = item.is_folder;
const iconHtml = item.thumbnail
? ``
: `${isFolder ? ICONS.folderOpen : ICONS.music}
`;
const subtitle = item.subtitle
? `${this._esc(item.subtitle)}
` : "";
const chevron = isFolder
? `${ICONS.chevronRight}
` : "";
return `
${iconHtml}
${this._esc(item.title)}
${subtitle}
${ICONS.play}
${chevron}
`;
}
_renderLibCard(item, iconName) {
const thumb = item.thumbnail
? ` `
: `${ICONS[iconName] || ICONS.music}
`;
const action = iconName === "artist" ? "browse" : (iconName === "music" ? "play" : "play-queue");
const extra = iconName === "artist" ? `data-title="${this._esc(item.title)}" data-thumb="${this._esc(this._resolveImageUrl(item.thumbnail) || "")}"` : "";
const type = iconName === "artist" ? "artist" : (item.media_content_type || iconName);
const id = item.media_content_id || item.uri || "";
const artist = item.media_artist || item.subtitle || "";
const canQueue = ["album", "playlist", "track", "artist"].includes(type);
return `
${thumb}
${this._esc(item.title)}
${artist ? `
${this._esc(artist)}
` : ""}
${canQueue ? `
${ICONS.plus} ` : ""}
`;
}
_renderLibListItem(item) {
const thumb = item.thumbnail
? ` `
: "";
return `
${thumb}
${this._esc(item.title)}
${item.media_artist ? `
${this._esc(item.media_artist)}
` : ""}
${ICONS.plus}
${ICONS.play}
`;
}
/* ── Play an item ── */
async _playItem(contentId, contentType) {
if (!this._hass || !this._activePlayer) return;
this._debugLog("Play:", contentId, "type:", contentType, "on:", this._activePlayer);
try {
await this._callServiceSilent("music_assistant", "play_media", {
entity_id: this._activePlayer,
media_id: contentId,
enqueue: "replace",
});
} catch (_) {
try {
await this._callServiceSilent("media_player", "play_media", {
entity_id: this._activePlayer,
media_content_id: contentId,
media_content_type: contentType || "music",
});
} catch (err) {
this._debugLog("play_media error:", err);
if (this._isMediaNotFoundError(err)) {
this._showToast(this._t("errors.media_not_found"));
}
}
}
this._refreshQueueSoon(1500);
}
_callServiceSilent(domain, service, data) {
return this._hass.connection.sendMessagePromise({
type: "call_service",
domain,
service,
service_data: data,
});
}
/* ── Toast ── */
_showToast(msg, duration = 4000) {
const el = this.shadowRoot?.querySelector("#mml-toast");
if (!el) return;
el.textContent = msg;
el.classList.add("visible");
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => el.classList.remove("visible"), duration);
}
_isMediaNotFoundError(err) {
const msg = err?.message || String(err);
return msg.includes("no data") || msg.includes("DataException");
}
/* ── Utilities ── */
_esc(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
}
/* ─── Card Editor (WYSIWYG) ──────────────────────────────── */
const EDITOR_STYLES = `
:host { display: block; font-family: var(--paper-font-body1_-_font-family, Roboto, sans-serif); }
.editor { padding: 16px; }
.editor-section { margin-bottom: 20px; }
.editor-section-title {
font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--secondary-text-color, #727272); margin-bottom: 8px;
}
.editor-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.editor-row label { min-width: 120px; font-size: 14px; color: var(--primary-text-color, #212121); flex-shrink: 0; }
.editor-row input, .editor-row select {
flex: 1; padding: 8px; border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 4px; font-size: 14px; background: var(--card-background-color, #fff);
color: var(--primary-text-color, #212121); min-width: 0;
}
.editor-row input[type="checkbox"] { flex: none; width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color, #03a9f4); }
.editor-row input:focus, .editor-row select:focus {
outline: none; border-color: var(--primary-color, #03a9f4);
}
.tab-list { border: 1px solid var(--divider-color, #e0e0e0); border-radius: 8px; overflow: hidden; }
.tab-item {
border-bottom: 1px solid var(--divider-color, #e0e0e0);
background: var(--card-background-color, #fff);
}
.tab-item:last-child { border-bottom: none; }
.tab-item-header {
display: flex; align-items: center; gap: 6px; padding: 8px 12px; cursor: pointer;
user-select: none; -webkit-tap-highlight-color: transparent;
}
.tab-item-header:hover { background: var(--secondary-background-color, #f5f5f5); }
.tab-item-type {
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
padding: 2px 6px; border-radius: 3px;
background: var(--primary-color, #03a9f4); color: #fff;
}
.tab-item-type.button { background: var(--accent-color, #ff9800); }
.tab-item-label { flex: 1; font-size: 14px; font-weight: 500; color: var(--primary-text-color, #212121); }
.tab-item-actions { display: flex; gap: 2px; }
.tab-item-actions button {
background: none; border: none; cursor: pointer; padding: 4px;
color: var(--secondary-text-color, #727272); border-radius: 4px;
font-size: 16px; line-height: 1; min-width: 28px; min-height: 28px;
display: flex; align-items: center; justify-content: center;
}
.tab-item-actions button:hover { background: var(--secondary-background-color, #f5f5f5); color: var(--primary-text-color, #212121); }
.tab-item-actions button.delete:hover { color: var(--error-color, #db4437); }
.tab-item-actions button:disabled { opacity: 0.3; pointer-events: none; }
.tab-item-body { padding: 8px 12px 12px; border-top: 1px solid var(--divider-color, #e0e0e0); }
.tab-item-body .editor-row { margin-bottom: 6px; }
.tab-item-body .editor-row label { min-width: 100px; font-size: 13px; }
.tab-item-body .editor-row input, .tab-item-body .editor-row select { font-size: 13px; padding: 6px; }
.section-list { margin-top: 4px; }
.section-item {
display: flex; align-items: center; gap: 6px; padding: 4px 0;
}
.section-item label { flex: 1; font-size: 13px; cursor: pointer; user-select: none; }
.section-item input[type="checkbox"] { margin: 0; cursor: pointer; accent-color: var(--primary-color, #03a9f4); }
.section-item button {
background: none; border: none; cursor: pointer; padding: 2px;
color: var(--secondary-text-color, #727272); font-size: 14px; line-height: 1;
min-width: 24px; min-height: 24px; display: flex; align-items: center; justify-content: center;
border-radius: 4px;
}
.section-item button:hover { background: var(--secondary-background-color, #f5f5f5); }
.section-item button:disabled { opacity: 0.3; pointer-events: none; }
.add-tab-row { padding: 8px 12px; }
.add-tab-btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
width: 100%; padding: 8px; border: 2px dashed var(--divider-color, #e0e0e0);
border-radius: 6px; background: none; cursor: pointer;
color: var(--primary-color, #03a9f4); font-size: 14px; font-weight: 500;
}
.add-tab-btn:hover { border-color: var(--primary-color, #03a9f4); background: rgba(3,169,244,0.04); }
.add-tab-menu {
display: flex; flex-wrap: wrap; gap: 4px; padding: 8px 12px;
border-top: 1px solid var(--divider-color, #e0e0e0);
}
.add-tab-menu button {
padding: 6px 12px; border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 4px; background: var(--card-background-color, #fff); cursor: pointer;
font-size: 13px; color: var(--primary-text-color, #212121);
}
.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); }
`;
class MyMusicLibraryCardEditor extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._config = {};
this._hass = null;
this._expandedTab = -1;
this._showAddMenu = false;
}
_t(key) {
const raw = this._hass?.locale?.language || this._hass?.language || "en";
const lang = raw.toLowerCase().split("-")[0];
const dict = TRANSLATIONS[lang] || TRANSLATIONS.en;
const val = key.split(".").reduce((o, k) => o?.[k], dict);
if (val !== undefined) return val;
return key.split(".").reduce((o, k) => o?.[k], TRANSLATIONS.en) ?? key;
}
setConfig(config) {
this._config = { ...config };
if (this.shadowRoot) this._render();
}
set hass(hass) {
this._hass = hass;
if (this.shadowRoot && !this.shadowRoot.querySelector(".editor")) this._render();
}
_fireChanged() {
const config = { ...this._config };
this.dispatchEvent(new CustomEvent("config-changed", { detail: { config }, bubbles: true, composed: true }));
}
_getResolvedTabs() {
if (this._config.tabs && Array.isArray(this._config.tabs)) return [...this._config.tabs];
const tabs = [];
if (this._config.nav_buttons_left) {
for (const b of this._config.nav_buttons_left) tabs.push({ type: "button", ...b });
}
tabs.push({ type: "player" });
tabs.push({ type: "search" });
tabs.push({ type: "library" });
if (this._config.nav_buttons_right) {
for (const b of this._config.nav_buttons_right) tabs.push({ type: "button", ...b });
}
tabs.push({ type: "settings" });
return tabs;
}
_updateTabs(tabs) {
this._config = { ...this._config, tabs };
delete this._config.nav_buttons_left;
delete this._config.nav_buttons_right;
this._fireChanged();
this._render();
}
_tabTypeLabel(type) {
return this._t(`editor.type_${type}`) || type;
}
_tabDisplayLabel(tab) {
if (tab.label) return tab.label;
if (tab.name) return tab.name;
if (tab.type === "button") return tab.icon || "Button";
return this._t(`tabs.${tab.type}`) || tab.type;
}
_render() {
const root = this.shadowRoot;
root.innerHTML = "";
const style = document.createElement("style");
style.textContent = EDITOR_STYLES;
root.appendChild(style);
const wrap = document.createElement("div");
wrap.className = "editor";
const tabs = this._getResolvedTabs();
const panelTypes = tabs.filter(t => t.type !== "button").map(t => t.type);
const defaultTabOptions = panelTypes.length ? panelTypes : ["player", "search", "library"];
wrap.innerHTML = `
${this._renderBasicFields(defaultTabOptions)}
${this._t("editor.tabs_title")}
${tabs.map((tab, i) => this._renderTabItem(tab, i, tabs.length)).join("")}
${this._showAddMenu ? `
` : `
+ ${this._t("editor.add_tab")}
`}
`;
root.appendChild(wrap);
this._attachEditorListeners(wrap, tabs);
}
_renderBasicFields(defaultTabOptions) {
const cfg = this._config;
return `
`;
}
_renderTabItem(tab, index, total) {
const isExpanded = this._expandedTab === index;
const typeClass = tab.type === "button" ? " button" : "";
return `
${isExpanded ? this._renderTabBody(tab, index) : ""}
`;
}
_renderTabBody(tab, index) {
if (tab.type === "button") return this._renderButtonBody(tab, index);
let body = `
`;
return body;
}
_renderSectionsEditor(tab, index) {
const ALL_SECTIONS = ["artists", "albums", "playlists", "tracks", "radios", "recently_played", "recently_added", "recommended", "flows"];
const current = tab.sections || ["artists", "albums", "playlists", "tracks"];
const ordered = [...current, ...ALL_SECTIONS.filter(s => !current.includes(s))];
const currentLayout = tab.layout || "lanes";
const enabledCount = current.length;
const gridDisabled = enabledCount > 1;
const gridHint = gridDisabled ? this._t("editor.layout_grid_disabled") : "";
return `
${this._t("editor.layout_label")}
${["lanes", "grid", "columns", "auto"].map(l => {
const dis = (l === "grid" && gridDisabled) ? " disabled" : "";
const label = this._t(`settings.layout_${l}`) + (l === "grid" && gridDisabled ? ` (⚠)` : "");
return `${label} `;
}).join("")}
${gridDisabled && currentLayout === "grid" ? `
${this._esc(gridHint)}
` : ""}
${this._t("editor.sections_title")}
${ordered.map((sec, si) => {
const enabled = current.includes(sec);
const posInCurrent = current.indexOf(sec);
return `
${this._t(`lib.${sec}`) || sec}
▲
= current.length - 1 ? "disabled" : ""}>▼
`;
}).join("")}
`;
}
_renderSearchLayoutEditor(tab, index) {
const current = tab.search_layout || "rows";
return `
${this._t("editor.search_layout")}
${["rows", "columns"].map(l =>
`${this._t(`editor.search_layout_${l}`)} `
).join("")}
`;
}
_renderButtonBody(tab, index) {
const actionType = tab.tap_action?.action || "none";
let actionFields = "";
if (actionType === "navigate") {
actionFields = `
${this._t("editor.btn_nav_path")}
`;
} else if (actionType === "url") {
actionFields = `
${this._t("editor.btn_url")}
`;
} else if (actionType === "call-service" || actionType === "perform-action") {
actionFields = `
${this._t("editor.btn_service")}
`;
}
return `
`;
}
_attachEditorListeners(wrap, tabs) {
// Basic fields
wrap.querySelector("#ed-default-tab")?.addEventListener("change", (e) => {
this._config = { ...this._config, default_tab: e.target.value };
this._fireChanged();
});
wrap.querySelector("#ed-entity")?.addEventListener("change", (e) => {
const val = e.target.value.trim();
this._config = { ...this._config };
if (val) this._config.entity = val; else delete this._config.entity;
this._fireChanged();
});
wrap.querySelector("#ed-height")?.addEventListener("change", (e) => {
const val = e.target.value.trim();
this._config = { ...this._config };
if (val) {
this._config.height = /^\d+$/.test(val) ? parseInt(val) : val;
} else {
delete this._config.height;
}
this._fireChanged();
});
wrap.querySelector("#ed-show-device")?.addEventListener("change", (e) => {
this._config = { ...this._config, show_device_select: e.target.checked };
this._fireChanged();
});
// Toggle expand
wrap.querySelectorAll("[data-toggle-idx]").forEach(el => {
el.addEventListener("click", (e) => {
if (e.target.closest("[data-move]") || e.target.closest("[data-delete]")) return;
const idx = parseInt(el.dataset.toggleIdx);
this._expandedTab = this._expandedTab === idx ? -1 : idx;
this._render();
});
});
// Move up/down
wrap.querySelectorAll("[data-move]").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
const dir = btn.dataset.move === "up" ? -1 : 1;
const t = [...tabs];
[t[idx], t[idx + dir]] = [t[idx + dir], t[idx]];
this._expandedTab = idx + dir;
this._updateTabs(t);
});
});
// Delete
wrap.querySelectorAll("[data-delete]").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.delete);
const t = [...tabs];
t.splice(idx, 1);
this._expandedTab = -1;
this._updateTabs(t);
});
});
// Add tab
wrap.querySelector("#add-tab-btn")?.addEventListener("click", () => {
this._showAddMenu = true;
this._render();
});
wrap.querySelectorAll("[data-add-type]").forEach(btn => {
btn.addEventListener("click", () => {
const type = btn.dataset.addType;
const t = [...tabs];
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" }; }
t.push(newTab);
this._showAddMenu = false;
this._expandedTab = t.length - 1;
this._updateTabs(t);
});
});
// Tab field edits (label, icon, name, entity)
wrap.querySelectorAll("[data-field]").forEach(input => {
input.addEventListener("change", () => {
const idx = parseInt(input.dataset.idx);
const field = input.dataset.field;
const val = input.value.trim();
const t = [...tabs];
t[idx] = { ...t[idx] };
if (val) t[idx][field] = val; else delete t[idx][field];
this._updateTabs(t);
});
});
// Button action type change
wrap.querySelectorAll("[data-btn-action-type]").forEach(select => {
select.addEventListener("change", () => {
const idx = parseInt(select.dataset.idx);
const action = select.value;
const t = [...tabs];
t[idx] = { ...t[idx], tap_action: { action } };
this._updateTabs(t);
});
});
// Button action field edits (navigation_path, url_path, perform_action)
wrap.querySelectorAll("[data-btn-field]").forEach(input => {
input.addEventListener("change", () => {
const idx = parseInt(input.dataset.idx);
const field = input.dataset.btnField;
const val = input.value.trim();
const t = [...tabs];
t[idx] = { ...t[idx], tap_action: { ...t[idx].tap_action, [field]: val } };
this._updateTabs(t);
});
});
// Layout select (library tab)
wrap.querySelectorAll("[data-layout-select]").forEach(sel => {
sel.addEventListener("change", () => {
const tabIdx = parseInt(sel.dataset.tabIdx);
const t = [...tabs];
t[tabIdx] = { ...t[tabIdx], layout: sel.value };
this._updateTabs(t);
});
});
// Search layout select
wrap.querySelectorAll("[data-search-layout-select]").forEach(sel => {
sel.addEventListener("change", () => {
const tabIdx = parseInt(sel.dataset.tabIdx);
const t = [...tabs];
t[tabIdx] = { ...t[tabIdx], search_layout: sel.value };
this._updateTabs(t);
});
});
// Section toggles
wrap.querySelectorAll("[data-sec-toggle]").forEach(el => {
const handler = () => {
const sec = el.dataset.secToggle;
const tabIdx = parseInt(el.dataset.tabIdx);
const t = [...tabs];
t[tabIdx] = { ...t[tabIdx] };
const current = [...(t[tabIdx].sections || ["artists","albums","playlists","tracks"])];
const pos = current.indexOf(sec);
if (pos >= 0) {
current.splice(pos, 1);
} else {
current.push(sec);
}
t[tabIdx].sections = current;
this._updateTabs(t);
};
if (el.tagName === "INPUT") el.addEventListener("change", handler);
else el.addEventListener("click", handler);
});
// Section reorder
wrap.querySelectorAll("[data-sec-move]").forEach(btn => {
btn.addEventListener("click", () => {
const sec = btn.dataset.sec;
const tabIdx = parseInt(btn.dataset.tabIdx);
const dir = btn.dataset.secMove === "up" ? -1 : 1;
const t = [...tabs];
t[tabIdx] = { ...t[tabIdx] };
const current = [...(t[tabIdx].sections || ["artists","albums","playlists","tracks"])];
const pos = current.indexOf(sec);
if (pos < 0) return;
[current[pos], current[pos + dir]] = [current[pos + dir], current[pos]];
t[tabIdx].sections = current;
this._updateTabs(t);
});
});
}
_esc(str) {
if (!str) return "";
return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
}
}
if (!customElements.get("my-music-library-card-editor")) {
customElements.define("my-music-library-card-editor", MyMusicLibraryCardEditor);
}
if (!customElements.get("my-music-library-card")) {
customElements.define("my-music-library-card", MyMusicLibraryCard);
}
// Self-announce
window.customCards = window.customCards || [];
window.customCards.push({
type: "my-music-library-card",
name: "My Music Library",
description: "A responsive music player for Home Assistant + Music Assistant",
preview: false,
documentationURL: "https://github.com/your-user/my-music-library",
});
console.info(
`%c MY-MUSIC-LIBRARY-CARD %c v${CARD_VERSION} `,
"background:#1db954;color:#000;font-weight:700;padding:2px 4px;border-radius:3px 0 0 3px",
"background:#333;color:#fff;padding:2px 4px;border-radius:0 3px 3px 0"
);