/** * 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 ``; } const label = t.label || this._t(`tabs.${t.type}`); const icon = t.iconOverride ? `` : (ICONS[t.defaultIcon] || ICONS.player); return ``; }).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 ` `; } _renderPlayerTab() { return `
${this._t("player.select_player")}
0:00 0:00
${this._t("queue.up_next")} ${this._isMobile ? `` : ""}
${this._t("queue.empty")}
`; } _renderSearchTab() { return `
${ICONS.search}
${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.loading")}
`; } _renderDeviceModal() { return ` `; } _renderSettingsModal() { return ` `; } /* ── 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 = ``; } else if (role === "available" && canGroup) { actionHtml = ``; } 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 ? `` : ""} ${item.can_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) ? `
${inner}
` : inner; return `
${label}
${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 ? `` : ""}
`; } _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 = `
${heroArt}
${this._esc(title)}
${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)}
` : ""}
`).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}`; }).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 `
${ICONS.folderOpen}
${ICONS.chevronRight}
`; } 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}
${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 ? `` : ""}
`; } _renderLibListItem(item) { const thumb = item.thumbnail ? `` : ""; return `
${thumb}
${this._esc(item.title)}
${item.media_artist ? `
${this._esc(item.media_artist)}
` : ""}
`; } /* ── 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 ? `
${["player","search","library","settings","button"].map(type => ` `).join("")}
` : `
`}
`; 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 `
${this._tabTypeLabel(tab.type)} ${this._esc(this._tabDisplayLabel(tab))}
${isExpanded ? this._renderTabBody(tab, index) : ""}
`; } _renderTabBody(tab, index) { if (tab.type === "button") return this._renderButtonBody(tab, index); let body = `
`; if (tab.type === "library") body += this._renderSectionsEditor(tab, index); if (tab.type === "search") body += this._renderSearchLayoutEditor(tab, index); 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 `
${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 `
`; }).join("")}
`; } _renderSearchLayoutEditor(tab, index) { const current = tab.search_layout || "rows"; return `
`; } _renderButtonBody(tab, index) { const actionType = tab.tap_action?.action || "none"; let actionFields = ""; if (actionType === "navigate") { actionFields = `
`; } else if (actionType === "url") { actionFields = `
`; } else if (actionType === "call-service" || actionType === "perform-action") { actionFields = `
`; } return `
${actionFields}
`; } _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" );