diff --git a/CHANGELOG.md b/CHANGELOG.md index 8517fef..e2a36e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-11-07 + +### Added + +- **Dashboard card**: `custom:sidebar-mini-player-card` puts the same player anywhere on a + dashboard. `entity` / `entities` limit it to particular players, and every configuration + option can be set per card (global options are the defaults). It matches your theme's + cards and hides itself when nothing is playing. Also listed in the card picker. +- `show_in_sidebar` option (default `true`): `false` shows the player only where you add the + dashboard card, for wall panels and other screens whose sidebar is hidden. + ## [1.1.1] - 2026-11-04 Thanks to @OpenDeernet for the fix (#8). @@ -72,7 +83,8 @@ Home Assistant install before being made generic and open-sourced. - HACS packaging, jsdom test suite (`npm test`) and GitHub Actions for HACS validation, tests and releases. -[Unreleased]: https://github.com/Evski/ha-sidebar-mini-player/compare/v1.1.1...HEAD +[Unreleased]: https://github.com/Evski/ha-sidebar-mini-player/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/Evski/ha-sidebar-mini-player/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/Evski/ha-sidebar-mini-player/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/Evski/ha-sidebar-mini-player/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/Evski/ha-sidebar-mini-player/releases/tag/v1.0.0 diff --git a/README.md b/README.md index ee9825a..349b993 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [Install with HACS](#install-with-hacs) - [Manual install](#manual-install) - [Configuration](#configuration) +- [Dashboard card](#dashboard-card) - [Gestures and controls](#gestures-and-controls) - [Mobile and the iOS companion app](#mobile-and-the-ios-companion-app) - [Theming](#theming) @@ -80,7 +81,7 @@ Add this to `configuration.yaml`: ```yaml frontend: extra_module_url: - - /hacsfiles/ha-sidebar-mini-player/ha-sidebar-mini-player.js?v=1.1.1 + - /hacsfiles/ha-sidebar-mini-player/ha-sidebar-mini-player.js?v=1.2.0 ``` Restart Home Assistant, then refresh your browser. In the companion app, reset the frontend cache: see [Mobile](#mobile-and-the-ios-companion-app). @@ -102,10 +103,10 @@ You can use both routes at once. The module notices it's already loaded and the ```yaml frontend: extra_module_url: - - /local/ha-sidebar-mini-player.js?v=1.1.1 + - /local/ha-sidebar-mini-player.js?v=1.2.0 ``` - Or add `/local/ha-sidebar-mini-player.js?v=1.1.1` as a dashboard resource (JavaScript module). + Or add `/local/ha-sidebar-mini-player.js?v=1.2.0` as a dashboard resource (JavaScript module). 4. Restart Home Assistant and refresh the browser. **Uninstall:** remove the line or resource, restart, refresh, and delete the file. The player creates no entities, helpers or storage in Home Assistant. @@ -128,7 +129,7 @@ window.sidebarMiniPlayerConfig = { ```yaml frontend: extra_module_url: - - /hacsfiles/ha-sidebar-mini-player/ha-sidebar-mini-player.js?v=1.1.1 + - /hacsfiles/ha-sidebar-mini-player/ha-sidebar-mini-player.js?v=1.2.0 - /local/sidebar-mini-player-config.js?v=1 ``` @@ -152,6 +153,28 @@ The player re-reads the object every second, so load order doesn't matter. You c | `show_shuffle` | boolean | `false` | Shuffle button left of previous. Only for players that support it; lit when shuffle is on. | | `show_repeat` | boolean | `false` | Repeat button right of next. Cycles off, all, one. Only for players that support it; lit when repeat is on. | | `remember_dismissed` | boolean | `true` | Remember dismissals across reloads, in this browser's local storage. `false` keeps them until the next reload. | +| `show_in_sidebar` | boolean | `true` | Dock the player in the sidebar. `false` shows it only where you add the [dashboard card](#dashboard-card), for example on a wall panel whose sidebar is hidden. | + +## Dashboard card + +The same player is also a dashboard card, so you can put it anywhere: a music view, a wall panel, a phone dashboard. It's in the same file, so there's nothing extra to install. + +```yaml +type: custom:sidebar-mini-player-card +entities: + - media_player.kitchen + - media_player.living_room +``` + +Or pick **Sidebar Mini Player** in the card picker. + +- `entity` or `entities` limit the card to those players. Leave them out to show whatever is playing, like the sidebar. +- Every [configuration](#configuration) option works on the card too, for that card only: `show_volume: false`, `show_shuffle: true`, `accent_color`, `paused_timeout_minutes` and so on. Options you set globally in `window.sidebarMiniPlayerConfig` are the card's defaults. `position` and `show_in_sidebar` don't apply to a card. +- Like the sidebar player, the card hides itself when nothing is playing or paused, and appears as soon as something starts. +- It uses your theme's card background, border and corners, so it matches the cards around it. +- The card is defined by the same file as the sidebar player. If you load the player with `extra_module_url`, the card is available on every dashboard. If you load it as a dashboard resource, it's available on dashboards too. + +On a wall panel, where the sidebar is hidden, set `show_in_sidebar: false` globally (or just leave the sidebar player, which costs very little) and add the card to the panel's dashboard. ## Gestures and controls @@ -206,7 +229,7 @@ The `accent_color` config option, if set, takes priority over the theme variable **The player doesn't appear.** 1. Is something actually **playing or paused**? Idle, on, standby and off players are hidden on purpose. -2. Open the browser console. You should see `SIDEBAR-MINI-PLAYER v1.1.1`, and `window.__sidebarMiniPlayerLoaded` should return the version. +2. Open the browser console. You should see `SIDEBAR-MINI-PLAYER v1.2.0`, and `window.__sidebarMiniPlayerLoaded` should return the version. 3. After adding `extra_module_url`, **restart Home Assistant**, then hard-refresh or reset the app's frontend cache. 4. Using the dashboard-resource route? Open any dashboard once in that tab. 5. Check `include` / `exclude` and `paused_timeout_minutes` in your config. diff --git a/ha-sidebar-mini-player.js b/ha-sidebar-mini-player.js index bb34638..f4fccb3 100644 --- a/ha-sidebar-mini-player.js +++ b/ha-sidebar-mini-player.js @@ -35,7 +35,7 @@ * Recommended - load on every page: * frontend: * extra_module_url: - * - /hacsfiles/ha-sidebar-mini-player/ha-sidebar-mini-player.js?v=1.1.1 + * - /hacsfiles/ha-sidebar-mini-player/ha-sidebar-mini-player.js?v=1.2.0 * (or /local/ha-sidebar-mini-player.js for a manual install), then restart * Home Assistant and refresh the browser / reset the companion app's * frontend cache. A plain Lovelace dashboard resource also works, but then @@ -116,7 +116,7 @@ /** Custom element tag + version (keep VERSION in sync with package.json and CHANGELOG.md). */ const TAG = "sidebar-mini-player"; - const VERSION = "1.1.1"; + const VERSION = "1.2.0"; // Loaded twice (e.g. extra_module_url AND a dashboard resource, or two // different URLs) -> the second copy does nothing. @@ -169,6 +169,9 @@ show_repeat: false, /** Keep dismissals across reloads (this browser's localStorage). false = until reload. */ remember_dismissed: true, + /** Dock the player in the sidebar. false = only show it where you add the dashboard card + * (custom:sidebar-mini-player-card), e.g. on wall panels whose sidebar is hidden. */ + show_in_sidebar: true, }); const POSITIONS = ["bottom", "top"]; @@ -575,6 +578,27 @@ // The player element. Own shadow root -> our CSS can't leak into the sidebar // and the sidebar's CSS can't break ours. Theme variables still inherit. // -------------------------------------------------------------------------- + /** + * Run fn with an element's own configuration (the dashboard card's options) + * swapped in for the global one. The helpers above read the module-level + * CFG / INCLUDE_RE / EXCLUDE_RE / cache, so the card swaps them for the + * duration of a render and restores them after. Synchronous, so nothing else + * can run in between. Elements without an override use the global config. + */ + function withCfg(el, fn) { + const o = el && el._cfgOverride; + if (!o) return fn(); + const saved = [CFG, INCLUDE_RE, EXCLUDE_RE, cache.states, cache.count, cache.ids]; + CFG = o.cfg; INCLUDE_RE = o.inc; EXCLUDE_RE = o.exc; + cache.states = o.cache.states; cache.count = o.cache.count; cache.ids = o.cache.ids; + try { + return fn(); + } finally { + o.cache.states = cache.states; o.cache.count = cache.count; o.cache.ids = cache.ids; + [CFG, INCLUDE_RE, EXCLUDE_RE, cache.states, cache.count, cache.ids] = saved; + } + } + class SidebarMiniPlayer extends HTMLElement { constructor() { super(); @@ -605,6 +629,17 @@ background: var(--sidebar-background-color, var(--card-background-color, inherit)); } :host([hidden]) { display: none !important; } + /* As a dashboard card: look like the theme's other cards, no sidebar margins. */ + :host([card]) { + --smp-card-bg: var(--sidebar-mini-player-card-background, var(--ha-card-background, var(--card-background-color, rgba(127,127,127,0.12)))); + } + :host([card]) .wrap { + margin: 0; + border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, rgba(127,127,127,0.2))); + box-shadow: var(--ha-card-box-shadow, none); + color: var(--primary-text-color); + } + :host([card]) .mini, :host([card]) .more { margin-left: 0; margin-right: 0; } [hidden] { display: none !important; } /* our own rules (e.g. display:flex) must not beat the hidden attribute */ /* Card look with breathing room - 8px from the sidebar edges and from the items above/below, 8-10px inner padding, theme card radius, @@ -972,7 +1007,7 @@ const now = (ev) => (ev && ev.timeStamp) || performance.now(); const excluded = (target) => !!(target && target.closest && target.closest(".vol, input")); const begin = (src, x, y, ev, extra) => { - if (!CFG.swipe_to_dismiss) return null; + if (!(this._cfgOverride ? this._cfgOverride.cfg : CFG).swipe_to_dismiss) return null; const id = entityOf(); if (!id) return null; return Object.assign({ src, x0: x, y0: y, mode: "pending", dx: 0, id, samples: [{ x, t: now(ev) }] }, extra); @@ -1215,6 +1250,10 @@ /** Called by the controller every tick with the current hass + collapsed flag. */ update(hass, collapsed) { + withCfg(this, () => this._update(hass, collapsed)); + } + + _update(hass, collapsed) { this._hass = hass; this._applyConfig(); if (collapsed !== this._collapsed) { @@ -1245,6 +1284,10 @@ } _render() { + return withCfg(this, () => this._renderNow()); + } + + _renderNow() { // Never re-render while a card is being // swiped (1 s tick / state updates could re-order, remove or re-fill the // card under the finger). end() unfreezes and renders. Capped by @@ -1486,6 +1529,95 @@ } if (!customElements.get(TAG)) customElements.define(TAG, SidebarMiniPlayer); + // -------------------------------------------------------------------------- + // DASHBOARD CARD: custom:sidebar-mini-player-card + // -------------------------------------------------------------------------- + /** + * The same player as a dashboard card you can put anywhere. Options are the + * global options (window.sidebarMiniPlayerConfig) with any card options on + * top, plus two shorthands: + * entity: media_player.kitchen (show only this player) + * entities: [media_player.a, media_player.b] + * "position" and "show_in_sidebar" don't apply to a card and are ignored. + * Like the sidebar player, the card hides itself when nothing is playing. + */ + const CARD_TAG = "sidebar-mini-player-card"; + const CARD_ONLY_KEYS = ["type", "entity", "entities", "view_layout", "grid_options", "layout_options", "visibility"]; + + class SidebarMiniPlayerCard extends HTMLElement { + constructor() { + super(); + this._hass = null; + this._timer = null; + this._player = document.createElement(TAG); + this._player.setAttribute("card", ""); + } + + setConfig(config) { + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error("sidebar-mini-player-card: invalid configuration"); + } + const global = window.sidebarMiniPlayerConfig; + const user = { ...(global && typeof global === "object" && !Array.isArray(global) ? global : {}) }; + for (const [k, v] of Object.entries(config)) if (!CARD_ONLY_KEYS.includes(k)) user[k] = v; + delete user.position; delete user.show_in_sidebar; + const only = [].concat(config.entities || [], config.entity || []).filter((x) => typeof x === "string" && x); + if (only.length) user.include = only; + const cfg = resolveConfig(user); + this._player._cfgOverride = { + cfg, + inc: cfg.include.map(toRegex), + exc: cfg.exclude.map(toRegex), + cache: { states: null, count: -1, ids: [] }, + }; + this._tick(); + } + + set hass(hass) { + this._hass = hass; + this._tick(); + } + + connectedCallback() { + if (this._player.parentNode !== this) this.appendChild(this._player); + // 1 s tick for the progress bar and the paused timeout, only while on screen. + if (!this._timer) this._timer = setInterval(() => this._tick(), TICK_MS); + this._tick(); + } + + disconnectedCallback() { + clearInterval(this._timer); + this._timer = null; + } + + _tick() { + if (!this._hass || !this._player._cfgOverride) return; + try { + this._player.update(this._hass, false); + } catch (err) { + console.warn(`[${CARD_TAG}] update error`, err); + } + } + + getCardSize() { return 2; } + + getGridOptions() { return { columns: 12, min_columns: 6, rows: "auto" }; } + + static getStubConfig() { return {}; } + } + + if (!customElements.get(CARD_TAG)) customElements.define(CARD_TAG, SidebarMiniPlayerCard); + window.customCards = window.customCards || []; + if (!window.customCards.some((c) => c.type === CARD_TAG)) { + window.customCards.push({ + type: CARD_TAG, + name: "Sidebar Mini Player", + description: "The now-playing mini player as a dashboard card: artwork, controls, progress and volume for whatever is playing.", + preview: false, + documentationURL: "https://github.com/evski/ha-sidebar-mini-player#dashboard-card", + }); + } + // -------------------------------------------------------------------------- // Controller: finds hass + the sidebar, injects the player, keeps it updated. // -------------------------------------------------------------------------- @@ -1677,6 +1809,10 @@ /** Make sure the player lives in the CURRENT sidebar (it can be replaced). */ function ensureInjected() { + if (!CFG.show_in_sidebar) { + if (player && player.isConnected) player.remove(); + return false; + } const sb = findSidebar(); if (!sb || !sb.shadowRoot) return false; if (sidebar === sb && inRightSpot(sb.shadowRoot)) return true; diff --git a/package.json b/package.json index e6db880..bafb860 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ha-sidebar-mini-player", - "version": "1.1.1", + "version": "1.2.0", "description": "Now-playing mini media player docked in the Home Assistant sidebar on every page. Auto-detects any media_player, Music Assistant aware, swipe to dismiss. HACS plugin.", "private": true, "main": "ha-sidebar-mini-player.js", diff --git a/test/test.js b/test/test.js index df8439f..98ffdd2 100644 --- a/test/test.js +++ b/test/test.js @@ -869,6 +869,54 @@ function setup(layoutHtml, statesFile = FIXTURE, prep = null, opts = {}) { } } + // ---------- K: dashboard card (custom:sidebar-mini-player-card) ---------- + { + const t = setup(real2026("light")); + await sleep(200); + const sidebarP = t.sb.shadowRoot.querySelector("sidebar-mini-player"); + const mk = (config) => { + const c = t.d.createElement("sidebar-mini-player-card"); + c.setConfig(config); t.d.body.appendChild(c); c.hass = t.hass; return c; + }; + const inner = (c) => c.querySelector("sidebar-mini-player"); + check("K1 card element defined and listed in window.customCards", + !!t.w.customElements.get("sidebar-mini-player-card") && (t.w.customCards || []).some((x) => x.type === "sidebar-mini-player-card")); + const all = mk({}); + await sleep(50); + check("K2 card with no options shows the most recent player, styled as a card", + !!inner(all) && !inner(all).hidden && inner(all)._entity === "media_player.office" && inner(all).hasAttribute("card"), inner(all) && inner(all)._entity); + const one = mk({ entity: "media_player.kitchen_2", show_volume: false }); + await sleep(50); + check("K3 entity: shows only that player", inner(one)._entity === "media_player.kitchen_2", inner(one)._entity); + check("K3 card option (show_volume false) applies to the card", inner(one).shadowRoot.querySelector(".focus .vol").hidden); + await sleep(1100); // let the sidebar player tick after the cards rendered + const g = t.w.sidebarMiniPlayer && t.w.sidebarMiniPlayer.config; + check("K4 card options don't leak into the sidebar player or the global config", + sidebarP && sidebarP._entity === "media_player.office" && !sidebarP._cfgOverride && g && g.show_volume === true && g.include.length === 0, + `${sidebarP && sidebarP._entity} show_volume=${g && g.show_volume} include=${g && g.include}`); + const none = mk({ entities: ["media_player.bedroom"] }); + await sleep(50); + check("K5 card hides when none of its players is active", inner(none).hidden); + t.states["media_player.bedroom"] = { ...t.states["media_player.bedroom"], state: "playing", last_changed: new Date().toISOString() }; + t.hass = { ...t.hass, states: { ...t.states } }; none.hass = t.hass; + await sleep(50); + check("K5 ...and shows when one starts playing", !inner(none).hidden && inner(none)._entity === "media_player.bedroom", inner(none)._entity); + let threw = false; try { t.d.createElement("sidebar-mini-player-card").setConfig(null); } catch (e) { threw = true; } + check("K6 invalid card config throws (the card editor shows the error)", threw); + one.remove(); await sleep(20); + check("K7 removed card stops its 1 s timer", one._timer === null); + t.w.close(); + + // show_in_sidebar: false -> no sidebar player, the card still works + const t2 = setup(real2026("light"), FIXTURE, null, { config: { show_in_sidebar: false } }); + await sleep(1300); + check("K8 show_in_sidebar false: nothing injected into the sidebar", !t2.sb.shadowRoot.querySelector("sidebar-mini-player")); + const c2 = t2.d.createElement("sidebar-mini-player-card"); c2.setConfig({}); t2.d.body.appendChild(c2); c2.hass = t2.hass; + await sleep(50); + check("K8 ...but the dashboard card still shows", !c2.querySelector("sidebar-mini-player").hidden); + t2.w.close(); + } + // ---------- P: packaging / loading routes ---------- { // extra_module_url timing: module runs BEFORE exists; config defined later.