Skip to content

Commit 985b19b

Browse files
committed
feat: full music assistant support
1 parent 77910f3 commit 985b19b

26 files changed

+2340
-443
lines changed

README.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ This card, Maxi Media Player, is a generalisation of the [Sonos Card](https://gi
2525
- Configurable styling
2626
- Dynamic volume level slider
2727
- Track progress bar
28-
- Show, play and rearrange tracks in play queue <!-- //#ONLY_SONOS_CARD -->
29-
- Set and clear sleep timer <!-- //#ONLY_SONOS_CARD -->
28+
- Show, play and rearrange tracks in play queue (Sonos and Music Assistant)
29+
- Set and clear sleep timer (Sonos)
3030
- Search for music via Music Assistant
3131

3232
and more!
@@ -96,6 +96,8 @@ sections: # Choose which sections to show in the card. Available sections are:
9696
- grouping
9797
- media browser
9898
- player
99+
- queue # Sonos or Music Assistant
100+
- search # Music Assistant
99101
```
100102
--> <!-- //#ONLY_SONOS_CARD -->
101103
@@ -115,6 +117,7 @@ entities: # Required unless you specify entityPlatform
115117
- media_player.livingroom_player
116118
excludeItemsInEntitiesList: true # Will invert the selection in the `entities` list, so that all players that are not in the list will be used.
117119
entityPlatform: sonos # will select all entities for this platform. Will override the `entities` list if set.
120+
# In the visual editor, the "Use Music Assistant" toggle sets entityPlatform: music_assistant
118121
```
119122
--> <!-- //#ONLY_SONOS_CARD -->
120123
<!-- //#ONLY_SONOS_CARD_START -->
@@ -166,6 +169,7 @@ dynamicVolumeSliderMax: 40 # default is 30. Use this to change the max value for
166169
dynamicVolumeSliderThreshold: 30 # default is 20. Use this to change the threshold for the dynamic volume slider.
167170
entitiesToIgnoreVolumeLevelFor: # default is empty. Use this if you want to ignore volume level for certain players in the player section. Useful if you have a main device with fixed volume.
168171
- media_player.my_sonos_port_device
172+
entityPlatform: music_assistant # default is empty.
169173
predefinedGroups: # defaults to empty. More advanced features in separate section further down.
170174
- name: Inside
171175
volume: 15 # If you want to set the volume of all speakers when grouping
@@ -383,8 +387,6 @@ inverseGroupMuteState: true # default is false, which means that only if all pla
383387
volumeStepSize: 1 # Use this to change the step size when using volume up/down. Default is to use the step size of Home Assistant's media player integration.
384388
```
385389
386-
<!-- //#ONLY_SONOS_CARD_START -->
387-
388390
### Queue Configuration
389391
390392
```yaml
@@ -396,8 +398,6 @@ queue:
396398
selectedItemTextColor: '#000000' # Use this to set a custom text color for the currently playing queue item.
397399
```
398400
399-
<!-- //#ONLY_SONOS_CARD_END -->
400-
401401
### Search Configuration
402402
403403
The Search section allows you to search for music using the [Music Assistant](https://music-assistant.io/) integration. You must have Music Assistant installed and configured in Home Assistant to use this feature.
@@ -409,9 +409,15 @@ search:
409409
defaultMediaType: track # default is none. Pre-select a media type (track, artist, album, playlist).
410410
searchLimit: 50 # default is 50. Maximum number of results to show per search.
411411
autoSearchMinChars: 3 # default is 3. Minimum characters before auto-search triggers.
412-
autoSearchDebounceMs: 1000 # default is 1000. Delay in milliseconds before auto-search executes.
413412
```
414413
414+
Queue supports:
415+
- Sonos queue (native Sonos integration)
416+
- Music Assistant queue (requires `entityPlatform: music_assistant`)
417+
418+
If you use Music Assistant queue, install [mass_queue](https://github.com/droans/mass_queue) from HACS.
419+
420+
415421
**Search Features:**
416422
- Search for tracks, artists, albums, or playlists
417423
- Auto-search as you type (configurable)

scripts/deploy.ts

Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ function loadToken(env: EnvConfig): string | null {
4242
return env.HA_TOKEN?.trim() || null;
4343
}
4444

45-
async function updateHacstag(): Promise<void> {
45+
const CARD_NAMES = ['custom-sonos-card', 'maxi-media-player'];
46+
47+
async function updateHacstags(): Promise<void> {
4648
const env = loadEnv();
4749
const token = loadToken(env);
4850

@@ -64,6 +66,8 @@ async function updateHacstag(): Promise<void> {
6466
return new Promise((resolve, reject) => {
6567
const ws = new WebSocket(wsUrl);
6668
let msgId = 1;
69+
let pendingUpdates: { resourceId: number; currentUrl: string; newUrl: string; cardName: string }[] = [];
70+
let completedUpdates = 0;
6771

6872
ws.on('message', (data: WebSocket.Data) => {
6973
const msg: HaMessage = JSON.parse(data.toString());
@@ -78,9 +82,40 @@ async function updateHacstag(): Promise<void> {
7882
ws.close();
7983
reject(new Error('Auth failed'));
8084
} else if (msg.type === 'result' && msg.id === 1) {
81-
handleResourcesResult(msg, ws, msgId++, resolve, reject);
82-
} else if (msg.type === 'result' && msg.id === 2) {
83-
handleUpdateResult(msg, ws, resolve, reject);
85+
pendingUpdates = buildUpdates(msg, reject);
86+
if (pendingUpdates.length === 0) {
87+
ws.close();
88+
reject(new Error('No resources found to update'));
89+
return;
90+
}
91+
for (const update of pendingUpdates) {
92+
const id = msgId++;
93+
console.log(`\n[${update.cardName}]`);
94+
console.log(` Resource ID: ${update.resourceId}`);
95+
console.log(` Current: ${update.currentUrl}`);
96+
console.log(` New: ${update.newUrl}`);
97+
ws.send(
98+
JSON.stringify({
99+
id,
100+
type: 'lovelace/resources/update',
101+
resource_id: update.resourceId,
102+
url: update.newUrl,
103+
}),
104+
);
105+
}
106+
} else if (msg.type === 'result' && msg.id && msg.id >= 2) {
107+
if (msg.success) {
108+
completedUpdates++;
109+
if (completedUpdates === pendingUpdates.length) {
110+
console.log(`\n✅ All ${completedUpdates} hacstags updated successfully!`);
111+
ws.close();
112+
resolve();
113+
}
114+
} else {
115+
console.error('Failed to update:', msg.error);
116+
ws.close();
117+
reject(new Error('Update failed'));
118+
}
84119
}
85120
});
86121

@@ -91,65 +126,40 @@ async function updateHacstag(): Promise<void> {
91126
});
92127
}
93128

94-
function handleResourcesResult(
129+
function buildUpdates(
95130
msg: HaMessage,
96-
ws: WebSocket,
97-
msgId: number,
98-
_resolve: () => void,
99131
reject: (err: Error) => void,
100-
): void {
132+
): { resourceId: number; currentUrl: string; newUrl: string; cardName: string }[] {
101133
const resources = msg.result ?? [];
102-
const sonosResource = resources.find((r) => r.url?.includes('custom-sonos-card'));
134+
const updates: { resourceId: number; currentUrl: string; newUrl: string; cardName: string }[] = [];
103135

104-
if (!sonosResource) {
105-
console.error('Could not find custom-sonos-card resource!');
106-
ws.close();
107-
reject(new Error('Resource not found'));
108-
return;
109-
}
136+
for (const cardName of CARD_NAMES) {
137+
const resource = resources.find((r) => r.url?.includes(cardName));
110138

111-
const currentUrl = sonosResource.url;
112-
const resourceId = sonosResource.id;
139+
if (!resource) {
140+
console.error(`Could not find ${cardName} resource!`);
141+
reject(new Error(`Resource not found: ${cardName}`));
142+
return [];
143+
}
113144

114-
const match = currentUrl.match(/hacstag=(\d+)/);
115-
if (!match) {
116-
console.error(`No hacstag found in URL: ${currentUrl}`);
117-
ws.close();
118-
reject(new Error('No hacstag'));
119-
return;
120-
}
145+
const match = resource.url.match(/hacstag=(\d+)/);
146+
if (!match) {
147+
console.error(`No hacstag found in URL: ${resource.url}`);
148+
reject(new Error(`No hacstag for ${cardName}`));
149+
return [];
150+
}
121151

122-
const currentTag = parseInt(match[1], 10);
123-
const newTag = currentTag + 1;
124-
const newUrl = currentUrl.replace(`hacstag=${currentTag}`, `hacstag=${newTag}`);
125-
126-
console.log(`Resource ID: ${resourceId}`);
127-
console.log(`Current: ${currentUrl}`);
128-
console.log(`New: ${newUrl}`);
129-
130-
ws.send(
131-
JSON.stringify({
132-
id: msgId,
133-
type: 'lovelace/resources/update',
134-
resource_id: resourceId,
135-
url: newUrl,
136-
}),
137-
);
138-
}
152+
const currentTag = parseInt(match[1], 10);
153+
const newTag = currentTag + 1;
154+
const newUrl = resource.url.replace(`hacstag=${currentTag}`, `hacstag=${newTag}`);
139155

140-
function handleUpdateResult(msg: HaMessage, ws: WebSocket, resolve: () => void, reject: (err: Error) => void): void {
141-
if (msg.success) {
142-
console.log('\n✅ Hacstag updated successfully!');
143-
ws.close();
144-
resolve();
145-
} else {
146-
console.error('Failed to update:', msg.error);
147-
ws.close();
148-
reject(new Error('Update failed'));
156+
updates.push({ resourceId: resource.id, currentUrl: resource.url, newUrl, cardName });
149157
}
158+
159+
return updates;
150160
}
151161

152-
updateHacstag().catch((err: Error) => {
162+
updateHacstags().catch((err: Error) => {
153163
console.error(err.message);
154164
process.exit(1);
155165
});

src/card.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import './components/source';
1212
import { ACTIVE_PLAYER_EVENT, CALL_MEDIA_DONE, CALL_MEDIA_STARTED } from './constants';
1313
import { when } from 'lit/directives/when.js';
1414
import { styleMap } from 'lit-html/directives/style-map.js';
15-
import { cardDoesNotContainAllSections, getHeight, getWidth, isSonosCard } from './utils/utils';
15+
import { cardDoesNotContainAllSections, getHeight, getWidth, isQueueSupported, isSonosCard } from './utils/utils';
1616

1717
const { GROUPING, GROUPS, MEDIA_BROWSER, PLAYER, VOLUMES, QUEUE, SEARCH } = Section;
1818
const TITLE_HEIGHT = 2;
@@ -27,6 +27,7 @@ export class Card extends LitElement {
2727
@state() loaderTimestamp!: number;
2828
@state() cancelLoader!: boolean;
2929
@state() activePlayerId?: string;
30+
@state() configError: string | null = null;
3031

3132
render() {
3233
this.createStore();
@@ -47,6 +48,7 @@ export class Card extends LitElement {
4748
>
4849
</div>
4950
${title ? html`<div class="title">${title}</div>` : html``}
51+
${this.configError ? html`<div class="no-players">${this.configError}</div>` : html``}
5052
<div class="content" style=${this.contentStyle(contentHeight)}>
5153
${
5254
this.activePlayerId
@@ -251,8 +253,8 @@ export class Card extends LitElement {
251253
delete newConfig[key];
252254
}
253255
}
254-
const sections =
255-
newConfig.sections || Object.values(Section).filter((section) => isSonosCard(newConfig) || section !== QUEUE);
256+
const showQueue = isQueueSupported(newConfig);
257+
const sections = newConfig.sections || Object.values(Section).filter((section) => showQueue || section !== QUEUE);
256258
if (newConfig.startSection && sections.includes(newConfig.startSection)) {
257259
this.section = newConfig.startSection;
258260
} else if (sections) {
@@ -266,7 +268,7 @@ export class Card extends LitElement {
266268
? GROUPING
267269
: sections.includes(SEARCH)
268270
? SEARCH
269-
: sections.includes(QUEUE) && isSonosCard(newConfig)
271+
: sections.includes(QUEUE) && showQueue
270272
? QUEUE
271273
: VOLUMES;
272274
} else {
@@ -286,9 +288,22 @@ export class Card extends LitElement {
286288
newConfig.entityPlatform = undefined;
287289
}
288290
}
291+
this.configError = this.getConfigError(newConfig);
289292
this.config = newConfig;
290293
}
291294

295+
private getConfigError(config: CardConfig): string | null {
296+
const isMusicAssistant = config.entityPlatform === 'music_assistant';
297+
const hasShowNonSonos = !!config.showNonSonosPlayers;
298+
const hasOtherPlatform =
299+
!!config.entityPlatform && config.entityPlatform !== 'music_assistant' && config.entityPlatform !== 'sonos';
300+
const activeCount = [isMusicAssistant, hasShowNonSonos, hasOtherPlatform].filter(Boolean).length;
301+
if (activeCount > 1) {
302+
return 'Conflicting configuration: only one of useMusicAssistant, showNonSonosPlayers, or entityPlatform can be set at a time. Please fix your configuration.';
303+
}
304+
return null;
305+
}
306+
292307
static get styles() {
293308
return css`
294309
:host {

src/components/footer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { css, html, LitElement } from 'lit';
22
import { property } from 'lit/decorators.js';
33
import { CardConfig, Section } from '../types';
44
import './section-button';
5-
import { isSonosCard } from '../utils/utils';
5+
import { isQueueSupported } from '../utils/utils';
66

77
const { GROUPING, GROUPS, MEDIA_BROWSER, PLAYER, VOLUMES, QUEUE, SEARCH } = Section;
88

@@ -21,7 +21,7 @@ class Footer extends LitElement {
2121
[SEARCH, icons?.search ?? 'mdi:magnify'],
2222
[VOLUMES, icons?.volumes ?? 'mdi:tune'],
2323
];
24-
if (!isSonosCard(this.config)) {
24+
if (!isQueueSupported(this.config)) {
2525
sections = sections.filter(([section]) => section !== QUEUE);
2626
}
2727
sections = sections.filter(([section]) => !this.config.sections || this.config.sections?.includes(section));

0 commit comments

Comments
 (0)