Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand All @@ -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.
Expand All @@ -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
```

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
142 changes: 139 additions & 3 deletions ha-sidebar-mini-player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"];

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
// --------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
48 changes: 48 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <home-assistant> exists; config defined later.
Expand Down
Loading