5052 lines
202 KiB
JavaScript
5052 lines
202 KiB
JavaScript
/**
|
||
* 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: `<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`,
|
||
pause: `<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`,
|
||
stop: `<svg viewBox="0 0 24 24"><path d="M6 6h12v12H6z"/></svg>`,
|
||
prev: `<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>`,
|
||
next: `<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6h2v12h-2z"/></svg>`,
|
||
shuffle: `<svg viewBox="0 0 24 24"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>`,
|
||
repeat: `<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>`,
|
||
repeatOne: `<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"/></svg>`,
|
||
volumeHigh: `<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>`,
|
||
volumeMute: `<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>`,
|
||
search: `<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>`,
|
||
library: `<svg viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9h-4v4h-2v-4H9V9h4V5h2v4h4v2z"/></svg>`,
|
||
player: `<svg viewBox="0 0 24 24"><path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"/></svg>`,
|
||
heart: `<svg viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`,
|
||
heartOutline: `<svg viewBox="0 0 24 24"><path d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"/></svg>`,
|
||
device: `<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 2.5c1.38 0 2.5 1.12 2.5 2.5S13.38 11.5 12 11.5 9.5 10.38 9.5 9s1.12-2.5 2.5-2.5zM20 18H4v-.57c0-.81.48-1.53 1.22-1.85C6.88 14.96 9.26 14.5 12 14.5s5.12.46 6.78 1.08c.74.32 1.22 1.04 1.22 1.85V18z"/></svg>`,
|
||
close: `<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`,
|
||
settings: `<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.04.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>`,
|
||
music: `<svg viewBox="0 0 24 24"><path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"/></svg>`,
|
||
album: `<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>`,
|
||
artist: `<svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>`,
|
||
playlist: `<svg viewBox="0 0 24 24"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>`,
|
||
chevronLeft: `<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>`,
|
||
chevronRight: `<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>`,
|
||
plus: `<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`,
|
||
group: `<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>`,
|
||
folder: `<svg viewBox="0 0 24 24"><path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`,
|
||
folderOpen: `<svg viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/></svg>`,
|
||
home: `<svg viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>`,
|
||
radio: `<svg viewBox="0 0 24 24"><path d="M20 6H8.3L20.1 3.2 19.6 1.3 2 5.5V20c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-8 11c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/></svg>`,
|
||
queue: `<svg viewBox="0 0 24 24"><path d="M3 5.5h18v3H3V5.5zm0 5h18v3H3v-3zm0 5h12v3H3v-3z"/></svg>`,
|
||
remove: `<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`,
|
||
addNext: `<svg viewBox="0 0 24 24"><path d="M21 3H3v18h18V3zm-4 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>`,
|
||
history: `<svg viewBox="0 0 24 24"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>`,
|
||
sparkle: `<svg viewBox="0 0 24 24"><path d="M12 2L9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2z"/></svg>`,
|
||
newBox: `<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm-7.5 12H11V10.5H9.5V9h3v7zm5.5 0h-1.5v-2.5H15V16h-1.5V9H15v2.5h1.5V9H18v7z"/></svg>`,
|
||
wave: `<svg viewBox="0 0 24 24"><path d="M21 6c-1.66 0-3 1.34-3 3 0 .55.15 1.06.41 1.5L15 14.5l-2.59-2.59c.35-.51.59-1.12.59-1.91 0-1.66-1.34-3-3-3s-3 1.34-3 3c0 .79.24 1.4.59 1.91L3 16.5 4.5 18l5-5L12 15.5l5-5 .5.5c.44.26.95.41 1.5.41 1.66 0 3-1.34 3-3s-1.34-3-3-3z"/></svg>`,
|
||
};
|
||
|
||
/* ─── 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 `<span class="svg-icon">${ICONS[name] || ""}</span>`;
|
||
}
|
||
|
||
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()}
|
||
<div class="content">
|
||
${panels.map(t => panelRenderers[t.type] ? panelRenderers[t.type](t) : "").join("")}
|
||
</div>
|
||
${this._renderDeviceModal()}
|
||
${this._renderSettingsModal()}
|
||
<div class="mml-toast" id="mml-toast"></div>
|
||
`;
|
||
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
|
||
? `<ha-icon icon="${this._esc(t.iconOverride)}"></ha-icon>`
|
||
: ICONS.settings;
|
||
const debugStyle = this._debugMode ? ' style="color: orange;"' : '';
|
||
return `<button class="nav-tab"${debugStyle} data-tab="settings" title="${this._esc(label)}">
|
||
${icon}<span>${this._esc(label)}</span>
|
||
</button>`;
|
||
}
|
||
const label = t.label || this._t(`tabs.${t.type}`);
|
||
const icon = t.iconOverride
|
||
? `<ha-icon icon="${this._esc(t.iconOverride)}"></ha-icon>`
|
||
: (ICONS[t.defaultIcon] || ICONS.player);
|
||
return `<button class="nav-tab ${this._tab === t.id ? "active" : ""}" data-tab="${t.id}">
|
||
${icon}<span>${this._esc(label)}</span>
|
||
</button>`;
|
||
}).join("");
|
||
|
||
return `
|
||
<div class="nav-wrapper">
|
||
<div class="nav-fade-left"></div>
|
||
<div class="nav-fade-right"></div>
|
||
<nav class="nav">
|
||
<div class="nav-tabs">${items}</div>
|
||
</nav>
|
||
</div>`;
|
||
}
|
||
|
||
_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 `
|
||
<button class="nav-btn${isActive ? " active" : ""}"
|
||
data-tab-btn="${btn.id}"
|
||
title="${this._esc(title)}"${sizeStyle}>
|
||
<ha-icon icon="${this._esc(icon)}"></ha-icon>
|
||
${label ? `<span class="nav-btn-label">${this._esc(label)}</span>` : ""}
|
||
</button>`;
|
||
}
|
||
|
||
_renderPlayerTab() {
|
||
return `
|
||
<div class="tab-panel" data-panel="player">
|
||
<div class="player-tab-body">
|
||
<div class="player-panel">
|
||
<div class="player-art-section">
|
||
<div class="art-wrapper" id="art-wrapper"></div>
|
||
</div>
|
||
<div class="player-controls-section">
|
||
<div class="track-info">
|
||
<div class="track-title" id="track-title">—</div>
|
||
<div class="track-artist" id="track-artist">${this._t("player.select_player")}</div>
|
||
</div>
|
||
<div class="progress-wrapper">
|
||
<div class="progress-bar-container" id="progress-bar">
|
||
<div class="progress-bar-fill" id="progress-fill" style="width:0%"></div>
|
||
</div>
|
||
<div class="progress-times">
|
||
<span id="pos-time">0:00</span>
|
||
<span id="dur-time">0:00</span>
|
||
</div>
|
||
</div>
|
||
<div class="controls">
|
||
<button class="ctrl-btn" id="btn-shuffle" title="${this._t("btns.shuffle")}">${ICONS.shuffle}</button>
|
||
<button class="ctrl-btn ctrl-nav" id="btn-prev" title="${this._t("btns.prev")}">${ICONS.prev}</button>
|
||
<button class="ctrl-btn primary" id="btn-playpause" title="${this._t("btns.play_pause")}">${ICONS.play}</button>
|
||
<button class="ctrl-btn ctrl-nav" id="btn-next" title="${this._t("btns.next")}">${ICONS.next}</button>
|
||
<button class="ctrl-btn" id="btn-repeat" title="${this._t("btns.repeat")}">${ICONS.repeat}</button>
|
||
<button class="queue-toggle-btn ${this._queueVisible ? "active" : ""}" id="btn-queue-toggle" title="${this._t("queue.toggle")}">${ICONS.queue}</button>
|
||
</div>
|
||
<div class="volume-row">
|
||
<button class="ctrl-btn" id="btn-mute" title="${this._t("btns.mute")}">${ICONS.volumeHigh}</button>
|
||
<input type="range" id="volume-slider" min="0" max="100" value="50">
|
||
</div>
|
||
<div class="device-row" id="device-row"${this._config.show_device_select === false ? ' style="display:none"' : ''}>
|
||
<span id="device-icon-wrap">${ICONS.device}</span>
|
||
<span class="device-name" id="device-name">${this._t("player.no_player")}</span>
|
||
${ICONS.chevronRight}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="queue-backdrop ${this._isMobile && this._queueVisible ? "open" : ""}" id="queue-backdrop"></div>
|
||
<div class="queue-section ${this._isMobile && this._queueVisible ? "mml-queue-open" : ""}" id="queue-section" style="${!this._isMobile && !this._queueVisible ? "display:none" : ""}">
|
||
<div class="queue-header">
|
||
<span class="queue-header-label">${this._t("queue.up_next")}</span>
|
||
${this._isMobile ? `<button class="queue-close-btn" id="queue-close-btn">${ICONS.close}</button>` : ""}
|
||
</div>
|
||
<div id="queue-list">
|
||
<div class="queue-empty">${this._t("queue.empty")}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderSearchTab() {
|
||
return `
|
||
<div class="tab-panel" data-panel="search">
|
||
<div id="search-main" class="search-panel">
|
||
<div class="search-bar-wrapper">
|
||
<div class="search-input-row">
|
||
${ICONS.search}
|
||
<input type="text" class="search-input" id="search-input"
|
||
placeholder="${this._t("search.placeholder")}" autocomplete="off" autocorrect="off">
|
||
</div>
|
||
</div>
|
||
<div class="results-container" id="search-results">
|
||
<div class="empty-state">
|
||
${ICONS.search}
|
||
<p>${this._t("search.type_hint")}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="artist-page" class="artist-page-panel" style="display:none"></div>
|
||
</div>`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tab-panel" data-panel="${panelId}">
|
||
<div class="library-panel">
|
||
<div class="lib-filters">
|
||
<div class="lib-filter-group">
|
||
<button class="lib-filter-btn ${src === "all" ? "active" : ""}" data-source="all">${this._t("lib.filter_all")}</button>
|
||
<button class="lib-filter-btn ${src === "local" ? "active" : ""}" data-source="local">${this._t("lib.filter_local")}</button>
|
||
<button class="lib-filter-btn ${src === "streaming" ? "active" : ""}" data-source="streaming">${this._t("lib.filter_streaming")}</button>
|
||
</div>
|
||
<div class="browse-mode-toggle" style="${src !== "local" ? "display:none" : ""}">
|
||
<button class="browse-mode-btn ${!browse ? "active" : ""}" data-browse="false">${this._t("lib.mode_catalogue")}</button>
|
||
<button class="browse-mode-btn ${browse ? "active" : ""}" data-browse="true">${this._t("lib.mode_browse")}</button>
|
||
</div>
|
||
<button class="lib-filter-fav ${fav ? "active" : ""}" style="${browse ? "display:none" : ""}">
|
||
${fav ? ICONS.heart : ICONS.heartOutline}<span>${this._t("lib.filter_favorites")}</span>
|
||
</button>
|
||
</div>
|
||
<div class="lib-content" id="lib-content-inner">
|
||
<div class="loader"><div class="spinner"></div> ${this._t("lib.loading")}</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderDeviceModal() {
|
||
return `
|
||
<div class="modal-overlay" id="device-modal">
|
||
<div class="modal-sheet">
|
||
<div class="modal-title">
|
||
<span>${this._t("group.title")}</span>
|
||
<button id="modal-close">${ICONS.close}</button>
|
||
</div>
|
||
<div id="device-list"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderSettingsModal() {
|
||
return `
|
||
<div class="modal-overlay" id="settings-modal">
|
||
<div class="modal-sheet">
|
||
<div class="modal-title">
|
||
<span>${this._t("settings.title")}</span>
|
||
<button id="settings-close">${ICONS.close}</button>
|
||
</div>
|
||
<div id="settings-content"></div>
|
||
<div style="text-align:right;font-size:12px;color:var(--text2);opacity:.7;margin-top:12px;">v${CARD_VERSION}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ── 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 = `<div class="art-placeholder">${ICONS.music}</div>`;
|
||
};
|
||
artWrapper.appendChild(img);
|
||
}
|
||
} else {
|
||
artWrapper.innerHTML = `<div class="art-placeholder">${ICONS.music}</div>`;
|
||
}
|
||
}
|
||
|
||
// 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 = `<div class="empty-state"><p>${this._t("group.no_players")}</p></div>`;
|
||
modal.classList.add("open");
|
||
return;
|
||
}
|
||
|
||
const addSection = (title, entries) => {
|
||
if (!entries.length) return;
|
||
const section = document.createElement("div");
|
||
section.className = "device-section";
|
||
section.innerHTML = `<div class="device-section-title">${title}</div>`;
|
||
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 = `<button class="device-item-action detach" title="${this._t("group.detach")}">${ICONS.close}</button>`;
|
||
} else if (role === "available" && canGroup) {
|
||
actionHtml = `<button class="device-item-action attach" title="${this._t("group.attach")}">${ICONS.plus}</button>`;
|
||
}
|
||
|
||
item.innerHTML = `
|
||
${iconSvg}
|
||
<span class="device-item-name">${this._esc(player.name)}</span>
|
||
<span class="device-item-state">${this._esc(player.state)}</span>
|
||
${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}
|
||
<input type="range" min="0" max="100" value="${volPct}" title="${this._t("group.volume")}">
|
||
<span class="device-vol-pct">${volPct}%</span>`;
|
||
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 ? `
|
||
<div class="settings-debug-banner">
|
||
<span class="settings-debug-dot"></span>
|
||
<div>
|
||
<strong>${this._t("settings.debug_active")}</strong>
|
||
<p class="settings-hint" style="margin:4px 0 0">${this._t("settings.debug_hint")}</p>
|
||
</div>
|
||
</div>` : "";
|
||
|
||
if (this._maProviders.length === 0) {
|
||
return `${debugBanner}<p style="color:var(--text2);font-size:13px;padding:8px 0;opacity:.7">${this._t("settings.providers_empty")}</p>`;
|
||
}
|
||
const rows = this._maProviders.map(p => {
|
||
const key = p.instance_id || p.domain;
|
||
const enabled = this._enabledProviders === null || this._enabledProviders.has(key);
|
||
return `
|
||
<div class="provider-item">
|
||
<span class="provider-name">${this._esc(p.name || p.domain)}</span>
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" data-provider="${this._esc(key)}"${enabled ? " checked" : ""}>
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>`;
|
||
}).join("");
|
||
return `
|
||
${debugBanner}
|
||
<div class="settings-section-title">${this._t("settings.providers_title")}</div>
|
||
<p class="settings-hint">${this._t("settings.providers_hint")}</p>
|
||
${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 = `<div class="loader"><div class="spinner"></div> ${this._t("search.searching")}</div>`;
|
||
|
||
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 = `<div class="empty-state">${ICONS.search}<p>${this._t("search.type_hint")}</p></div>`;
|
||
return;
|
||
}
|
||
if (results.error) {
|
||
el.innerHTML = `<div class="empty-state">${ICONS.search}
|
||
<p>${this._t("search.unavailable")}</p>
|
||
<p style="font-size:12px;margin-top:6px;opacity:.7">${this._t("search.player_label")}: <b>${this._activePlayer || "—"}</b></p>
|
||
<p style="font-size:11px;margin-top:4px;opacity:.5;word-break:break-word">${this._esc(results.error)}</p>
|
||
<p style="font-size:11px;margin-top:8px;opacity:.5">${this._t("search.console_hint")}</p>
|
||
</div>`;
|
||
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 = `<div class="empty-state">${ICONS.search}<p>${this._t("search.no_results")} « ${this._searchQuery} »</p></div>`;
|
||
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 = `<div class="search-section-title">${label}</div>`
|
||
+ 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 = `<div class="search-section-title">${label}</div>
|
||
<div class="lib-scroll">${shown.map(i => this._renderSearchCard(i, iconName)).join("")}</div>`;
|
||
} else {
|
||
secEl.innerHTML = `<div class="search-section-title">${label}</div>`
|
||
+ 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
|
||
? `<img class="result-thumb" src="${this._resolveImageUrl(item.thumbnail)}" alt="" loading="lazy">`
|
||
: `<div class="result-thumb-placeholder">${ICONS[iconName] || ICONS.music}</div>`;
|
||
const queueType = item.type === "track" || iconName === "music" ? "track" : item.type;
|
||
const canQueue = ["track", "music", "album", "playlist", "artist"].includes(queueType);
|
||
return `
|
||
<div class="result-item">
|
||
${thumb}
|
||
<div class="result-info">
|
||
<div class="result-title">${this._esc(item.title)}</div>
|
||
${item.subtitle ? `<div class="result-sub">${this._esc(item.subtitle)}</div>` : ""}
|
||
</div>
|
||
${canQueue ? `<button class="add-queue-btn" data-queue-id="${this._esc(item.id)}" data-queue-type="${this._esc(queueType)}" title="${this._t("queue.add_to_end")}">${ICONS.plus}</button>` : ""}
|
||
${item.can_play ? `<button class="result-play" data-action="play" data-id="${this._esc(item.id)}" data-type="${this._esc(item.type)}" title="${this._t("btns.play")}">${ICONS.play}</button>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
/* ── 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 = `<div class="loader" id="lib-loader"><div class="spinner"></div> ${this._t("lib.loading")}</div>`;
|
||
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
|
||
? `<div class="lib-sentinel-v" id="lib-sentinel-${type}"></div>`
|
||
: `<div class="lib-sentinel" id="lib-sentinel-${type}"></div>`;
|
||
|
||
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 = `<div class="${containerClass}" id="${containerId}">${itemsHtml}${sentinel}</div>`;
|
||
|
||
const body = (layout === "lanes" && !isTrackList)
|
||
? `<div class="lib-lane-wrap">
|
||
<button class="lib-lane-arrow left" data-lane="${type}" data-dir="left">${ICONS.chevronLeft}</button>
|
||
${inner}
|
||
<button class="lib-lane-arrow right" data-lane="${type}" data-dir="right">${ICONS.chevronRight}</button>
|
||
</div>`
|
||
: inner;
|
||
|
||
return `
|
||
<div class="lib-section" id="lib-sec-${type}">
|
||
<div class="lib-section-header">
|
||
<span class="lib-section-title">${label}</span>
|
||
</div>
|
||
${body}
|
||
</div>`;
|
||
};
|
||
|
||
// 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 =>
|
||
`<div class="lib-column" id="lib-slot-${s.type}"></div>`
|
||
).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 = `<div class="empty-state">${ICONS.library}
|
||
<p>${this._t("lib.empty")}</p>
|
||
<p style="font-size:11px;opacity:.6;margin-top:4px">${this._t("lib.empty_hint")}</p>
|
||
</div>`;
|
||
}
|
||
|
||
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
|
||
? `<img class="${artClass}" src="${this._resolveImageUrl(item.thumbnail)}" alt="" loading="lazy">`
|
||
: `<div class="${placeholderClass}">${ICONS[iconName] || ICONS.music}</div>`;
|
||
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 `
|
||
<div class="search-card" data-action="${action}" data-id="${this._esc(item.id)}" data-type="${type}" ${extra}>
|
||
${art}
|
||
<div class="search-card-name">${this._esc(item.title)}</div>
|
||
${item.subtitle ? `<div class="search-card-sub">${this._esc(item.subtitle)}</div>` : ""}
|
||
${canQueue ? `<button class="add-queue-btn" data-queue-id="${this._esc(item.id)}" data-queue-type="${this._esc(type)}" title="${this._t("queue.add_to_end")}">${ICONS.plus}</button>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_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
|
||
? `<img class="artist-hero-art" src="${this._resolveImageUrl(thumbnail)}" alt="" loading="lazy">`
|
||
: `<div class="artist-hero-art-placeholder">${ICONS.artist}</div>`;
|
||
|
||
artistPanel.innerHTML = `
|
||
<div class="artist-page-header">
|
||
<button class="back-btn" id="artist-back">${this._t("nav.back")}</button>
|
||
${heroArt}
|
||
<div class="artist-page-name">${this._esc(title)}</div>
|
||
<button class="add-queue-btn" id="artist-queue-btn" data-queue-id="${this._esc(id)}" data-queue-type="artist" title="${this._t("queue.add_to_end")}">${ICONS.plus}</button>
|
||
</div>
|
||
<div class="artist-page-sections">
|
||
<div class="loader"><div class="spinner"></div> ${this._t("lib.loading_short")}</div>
|
||
</div>
|
||
`;
|
||
|
||
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 += `
|
||
<div class="search-section">
|
||
<div class="search-section-title">${labelMap[t] || t}</div>
|
||
<div class="lib-scroll">
|
||
${group.map(i => this._renderSearchCard({ id: i.media_content_id, type: "album", title: i.title, subtitle: i.media_artist, thumbnail: i.thumbnail }, "album")).join("")}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
if (!sectionsHtml) {
|
||
sectionsHtml = `<div class="empty-state">${ICONS.library}<p>${this._t("lib.no_albums")}</p></div>`;
|
||
}
|
||
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 = `<div class="empty-state">${ICONS.library}<p>${this._t("lib.load_error")}</p></div>`;
|
||
});
|
||
}
|
||
|
||
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 = `<div class="queue-empty">${this._t("queue.empty")}</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = this._maQueueItems.map((t, i) => `
|
||
<div class="queue-item" data-queue-idx="${i}" data-queue-item-id="${this._esc(t.queue_item_id)}">
|
||
<div class="queue-num">${i + 1}</div>
|
||
<div class="queue-info">
|
||
<div class="queue-title">${this._esc(t.title)}</div>
|
||
${t.media_artist ? `<div class="queue-sub">${this._esc(t.media_artist)}</div>` : ""}
|
||
</div>
|
||
${t.duration ? `<div class="queue-dur">${fmt(t.duration)}</div>` : ""}
|
||
<button class="queue-remove" data-queue-item-id="${this._esc(t.queue_item_id)}" title="${this._t("queue.remove")}">${ICONS.remove}</button>
|
||
</div>
|
||
`).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 = `
|
||
<div class="queue-dropdown-item" data-mode="next">${this._t("queue.play_next")}</div>
|
||
<div class="queue-dropdown-item" data-mode="end">${this._t("queue.add_to_end")}</div>
|
||
<div class="queue-dropdown-item queue-dropdown-mix" data-mode="radio">${ICONS.shuffle} ${this._t("queue.start_mix")}</div>
|
||
`;
|
||
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 ? `<span class="browse-sep">›</span>` : "";
|
||
return `${sep}<button class="browse-crumb ${isLast ? "current" : ""}" data-crumb-idx="${i}">${this._esc(c.label)}</button>`;
|
||
}).join("");
|
||
return `<div class="browse-breadcrumb" id="browse-crumbs">${crumbHtml}</div>`;
|
||
}
|
||
|
||
_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()}
|
||
<div class="loader"><div class="spinner"></div> ${this._t("lib.loading_short")}</div>`;
|
||
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()}
|
||
<div id="browse-list">
|
||
${items.length
|
||
? items.map(item => this._renderBrowseItem(item)).join("")
|
||
: `<div class="empty-state">${ICONS.folder}<p>${this._t("lib.browse_empty")}</p></div>`
|
||
}
|
||
</div>`;
|
||
|
||
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()}
|
||
<div class="empty-state">${ICONS.library}<p>${this._t("lib.browse_error")}</p></div>`;
|
||
this._attachBrowseNav(libEl);
|
||
}
|
||
}
|
||
|
||
_renderBrowseItem(item) {
|
||
if (item.is_back) {
|
||
// MA virtual back item — render as a simple "go up" row, no play button
|
||
return `
|
||
<div class="browse-item" data-back="true" data-uri="" data-folder="false" data-title="">
|
||
<div class="browse-item-icon folder">${ICONS.folderOpen}</div>
|
||
<div class="browse-item-info">
|
||
<div class="browse-item-name">…</div>
|
||
</div>
|
||
<div class="browse-item-actions">
|
||
<div class="browse-chevron" style="transform:rotate(180deg)">${ICONS.chevronRight}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
const isFolder = item.is_folder;
|
||
const iconHtml = item.thumbnail
|
||
? `<div class="browse-item-icon"><img src="${this._esc(this._resolveImageUrl(item.thumbnail))}" alt="" loading="lazy"></div>`
|
||
: `<div class="browse-item-icon ${isFolder ? "folder" : ""}">${isFolder ? ICONS.folderOpen : ICONS.music}</div>`;
|
||
|
||
const subtitle = item.subtitle
|
||
? `<div class="browse-item-sub">${this._esc(item.subtitle)}</div>` : "";
|
||
|
||
const chevron = isFolder
|
||
? `<div class="browse-chevron">${ICONS.chevronRight}</div>` : "";
|
||
|
||
return `
|
||
<div class="browse-item"
|
||
data-uri="${this._esc(item.uri)}"
|
||
data-type="${this._esc(item.media_content_type || "music")}"
|
||
data-folder="${isFolder}"
|
||
data-back="false"
|
||
data-title="${this._esc(item.title)}">
|
||
${iconHtml}
|
||
<div class="browse-item-info">
|
||
<div class="browse-item-name">${this._esc(item.title)}</div>
|
||
${subtitle}
|
||
</div>
|
||
<div class="browse-item-actions">
|
||
<button class="browse-play-btn" title="${this._t("lib.browse_play")}">${ICONS.play}</button>
|
||
${chevron}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderLibCard(item, iconName) {
|
||
const thumb = item.thumbnail
|
||
? `<img class="lib-card-art" src="${this._resolveImageUrl(item.thumbnail)}" alt="" loading="lazy">`
|
||
: `<div class="lib-card-art-placeholder">${ICONS[iconName] || ICONS.music}</div>`;
|
||
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 `
|
||
<div class="lib-card" data-action="${action}" data-id="${this._esc(id)}" data-type="${this._esc(type)}" ${extra}>
|
||
${thumb}
|
||
<div class="lib-card-name">${this._esc(item.title)}</div>
|
||
${artist ? `<div class="lib-card-sub">${this._esc(artist)}</div>` : ""}
|
||
${canQueue ? `<button class="add-queue-btn" data-queue-id="${this._esc(id)}" data-queue-type="${this._esc(type)}" title="${this._t("queue.add_to_end")}">${ICONS.plus}</button>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_renderLibListItem(item) {
|
||
const thumb = item.thumbnail
|
||
? `<img class="lib-list-thumb" src="${this._resolveImageUrl(item.thumbnail)}" alt="" loading="lazy">`
|
||
: "";
|
||
return `
|
||
<div class="lib-list-item" data-action="play" data-id="${this._esc(item.media_content_id)}" data-type="${this._esc(item.media_content_type)}">
|
||
${thumb}
|
||
<div class="lib-list-info">
|
||
<div class="lib-list-title">${this._esc(item.title)}</div>
|
||
${item.media_artist ? `<div class="lib-list-sub">${this._esc(item.media_artist)}</div>` : ""}
|
||
</div>
|
||
<button class="add-queue-btn" data-queue-id="${this._esc(item.media_content_id)}" data-queue-type="track" title="${this._t("queue.add_to_end")}">${ICONS.plus}</button>
|
||
<button class="result-play" data-action="play" data-id="${this._esc(item.media_content_id)}" data-type="${this._esc(item.media_content_type)}" title="${this._t("btns.play")}">${ICONS.play}</button>
|
||
</div>`;
|
||
}
|
||
|
||
/* ── 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, ">")
|
||
.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)}
|
||
<div class="editor-section">
|
||
<div class="editor-section-title">${this._t("editor.tabs_title")}</div>
|
||
<div class="tab-list">
|
||
${tabs.map((tab, i) => this._renderTabItem(tab, i, tabs.length)).join("")}
|
||
</div>
|
||
${this._showAddMenu ? `
|
||
<div class="add-tab-menu">
|
||
${["player","search","library","settings","button"].map(type => `
|
||
<button data-add-type="${type}">${this._tabTypeLabel(type)}</button>
|
||
`).join("")}
|
||
</div>` : `
|
||
<div class="add-tab-row">
|
||
<button class="add-tab-btn" id="add-tab-btn">+ ${this._t("editor.add_tab")}</button>
|
||
</div>`}
|
||
</div>
|
||
`;
|
||
root.appendChild(wrap);
|
||
this._attachEditorListeners(wrap, tabs);
|
||
}
|
||
|
||
_renderBasicFields(defaultTabOptions) {
|
||
const cfg = this._config;
|
||
return `
|
||
<div class="editor-section">
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.default_tab")}</label>
|
||
<select id="ed-default-tab">
|
||
${defaultTabOptions.map(t => `<option value="${t}" ${cfg.default_tab === t ? "selected" : ""}>${this._t(`tabs.${t}`) || t}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.entity")}</label>
|
||
<input id="ed-entity" type="text" value="${cfg.entity || ""}" placeholder="${this._t("editor.entity_hint")}">
|
||
</div>
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.height")}</label>
|
||
<input id="ed-height" type="text" value="${cfg.height || ""}" placeholder="${this._t("editor.height_hint")}">
|
||
</div>
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.show_device_select")}</label>
|
||
<input id="ed-show-device" type="checkbox" ${cfg.show_device_select !== false ? "checked" : ""}>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderTabItem(tab, index, total) {
|
||
const isExpanded = this._expandedTab === index;
|
||
const typeClass = tab.type === "button" ? " button" : "";
|
||
return `
|
||
<div class="tab-item" data-tab-idx="${index}">
|
||
<div class="tab-item-header" data-toggle-idx="${index}">
|
||
<span class="expand-chevron ${isExpanded ? "open" : ""}">▶</span>
|
||
<span class="tab-item-type${typeClass}">${this._tabTypeLabel(tab.type)}</span>
|
||
<span class="tab-item-label">${this._esc(this._tabDisplayLabel(tab))}</span>
|
||
<div class="tab-item-actions">
|
||
<button data-move="up" data-idx="${index}" ${index === 0 ? "disabled" : ""} title="${this._t("editor.move_up")}">▲</button>
|
||
<button data-move="down" data-idx="${index}" ${index === total - 1 ? "disabled" : ""} title="${this._t("editor.move_down")}">▼</button>
|
||
<button class="delete" data-delete="${index}" title="${this._t("editor.delete")}">✕</button>
|
||
</div>
|
||
</div>
|
||
${isExpanded ? this._renderTabBody(tab, index) : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_renderTabBody(tab, index) {
|
||
if (tab.type === "button") return this._renderButtonBody(tab, index);
|
||
let body = `
|
||
<div class="tab-item-body">
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.tab_label")}</label>
|
||
<input data-field="label" data-idx="${index}" type="text" value="${this._esc(tab.label || "")}" placeholder="${this._t("editor.tab_label_hint")}">
|
||
</div>
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.tab_icon")}</label>
|
||
<input data-field="icon" data-idx="${index}" type="text" value="${this._esc(tab.icon || "")}" placeholder="${this._t("editor.tab_icon_hint")}">
|
||
</div>`;
|
||
if (tab.type === "library") body += this._renderSectionsEditor(tab, index);
|
||
if (tab.type === "search") body += this._renderSearchLayoutEditor(tab, index);
|
||
body += `</div>`;
|
||
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 `
|
||
<div style="margin-top:8px">
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.layout_label")}</label>
|
||
<select data-layout-select data-tab-idx="${index}">
|
||
${["lanes", "grid", "columns", "auto"].map(l => {
|
||
const dis = (l === "grid" && gridDisabled) ? " disabled" : "";
|
||
const label = this._t(`settings.layout_${l}`) + (l === "grid" && gridDisabled ? ` (⚠)` : "");
|
||
return `<option value="${l}" ${currentLayout === l ? "selected" : ""}${dis} title="${l === "grid" && gridDisabled ? this._esc(gridHint) : ""}">${label}</option>`;
|
||
}).join("")}
|
||
</select>
|
||
</div>
|
||
${gridDisabled && currentLayout === "grid" ? `<p style="font-size:11px;color:var(--error-color,#db4437);margin:0 0 4px">${this._esc(gridHint)}</p>` : ""}
|
||
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${this._t("editor.sections_title")}</div>
|
||
<div class="section-list">
|
||
${ordered.map((sec, si) => {
|
||
const enabled = current.includes(sec);
|
||
const posInCurrent = current.indexOf(sec);
|
||
return `
|
||
<div class="section-item">
|
||
<input type="checkbox" data-sec-toggle="${sec}" data-tab-idx="${index}" ${enabled ? "checked" : ""}>
|
||
<label data-sec-toggle="${sec}" data-tab-idx="${index}">${this._t(`lib.${sec}`) || sec}</label>
|
||
<button data-sec-move="up" data-sec="${sec}" data-tab-idx="${index}" ${!enabled || posInCurrent === 0 ? "disabled" : ""}>▲</button>
|
||
<button data-sec-move="down" data-sec="${sec}" data-tab-idx="${index}" ${!enabled || posInCurrent >= current.length - 1 ? "disabled" : ""}>▼</button>
|
||
</div>`;
|
||
}).join("")}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderSearchLayoutEditor(tab, index) {
|
||
const current = tab.search_layout || "rows";
|
||
return `
|
||
<div style="margin-top:8px">
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.search_layout")}</label>
|
||
<select data-search-layout-select data-tab-idx="${index}">
|
||
${["rows", "columns"].map(l =>
|
||
`<option value="${l}" ${current === l ? "selected" : ""}>${this._t(`editor.search_layout_${l}`)}</option>`
|
||
).join("")}
|
||
</select>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderButtonBody(tab, index) {
|
||
const actionType = tab.tap_action?.action || "none";
|
||
let actionFields = "";
|
||
if (actionType === "navigate") {
|
||
actionFields = `
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.btn_nav_path")}</label>
|
||
<input data-btn-field="navigation_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.navigation_path || "")}">
|
||
</div>`;
|
||
} else if (actionType === "url") {
|
||
actionFields = `
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.btn_url")}</label>
|
||
<input data-btn-field="url_path" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.url_path || "")}">
|
||
</div>`;
|
||
} else if (actionType === "call-service" || actionType === "perform-action") {
|
||
actionFields = `
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.btn_service")}</label>
|
||
<input data-btn-field="perform_action" data-idx="${index}" type="text" value="${this._esc(tab.tap_action?.perform_action || tab.tap_action?.service || "")}">
|
||
</div>`;
|
||
}
|
||
return `
|
||
<div class="tab-item-body">
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.btn_icon")}</label>
|
||
<input data-field="icon" data-idx="${index}" type="text" value="${this._esc(tab.icon || "")}" placeholder="mdi:home">
|
||
</div>
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.btn_name")}</label>
|
||
<input data-field="name" data-idx="${index}" type="text" value="${this._esc(tab.name || "")}">
|
||
</div>
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.btn_entity")}</label>
|
||
<input data-field="entity" data-idx="${index}" type="text" value="${this._esc(tab.entity || "")}" placeholder="light.living_room">
|
||
</div>
|
||
<div class="editor-row">
|
||
<label>${this._t("editor.btn_action_type")}</label>
|
||
<select data-btn-action-type data-idx="${index}">
|
||
${["none","toggle","more-info","navigate","url","call-service","assist"].map(a =>
|
||
`<option value="${a}" ${actionType === a ? "selected" : ""}>${this._t(`editor.action_${a.replace("-","_").replace("-","_")}`) || a}</option>`
|
||
).join("")}
|
||
</select>
|
||
</div>
|
||
${actionFields}
|
||
</div>`;
|
||
}
|
||
|
||
_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, ">").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"
|
||
);
|