Skip to content

Commit 409d77b

Browse files
committed
mvp mass
1 parent 5ff74e4 commit 409d77b

File tree

14 files changed

+1035
-198
lines changed

14 files changed

+1035
-198
lines changed

src/card.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
@@ -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/media-row.ts

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { css, html, LitElement, nothing, PropertyValues } from 'lit';
22
import { property } from 'lit/decorators.js';
33
import { classMap } from 'lit/directives/class-map.js';
4-
import { mdiSkipNext } from '@mdi/js';
4+
import { mdiBookshelf, mdiHeart, mdiHeartOutline, mdiSkipNext } from '@mdi/js';
55
import Store from '../model/store';
66
import { MediaPlayerItem } from '../types';
77
import { mediaItemTitleStyle } from '../constants';
@@ -19,6 +19,12 @@ class MediaRow extends LitElement {
1919
@property({ type: Boolean }) checked = false;
2020
@property({ type: Boolean }) showQueueButton = false;
2121
@property({ type: Boolean }) queueButtonDisabled = false;
22+
@property({ type: Boolean }) showFavoriteBadge = false;
23+
@property({ type: Boolean }) showLibraryBadge = false;
24+
@property({ type: Boolean }) isFavorite: boolean | null = null;
25+
@property({ type: Boolean }) favoriteLoading = false;
26+
@property({ type: Boolean }) isInLibrary: boolean | null = null;
27+
@property({ type: Boolean }) libraryLoading = false;
2228

2329
render() {
2430
const { itemBackgroundColor, itemTextColor, selectedItemBackgroundColor, selectedItemTextColor } =
@@ -28,9 +34,13 @@ class MediaRow extends LitElement {
2834
const cssVars =
2935
(bgColor ? `--secondary-background-color: ${bgColor};` : '') +
3036
(textColor ? `--secondary-text-color: ${textColor};` : '');
37+
const hasBadges =
38+
this.showFavoriteBadge || this.showLibraryBadge || this.isFavorite !== null || this.isInLibrary !== null;
39+
const showClickableHeart = this.isFavorite !== null;
40+
const showClickableLibrary = this.isInLibrary !== null;
3141
return html`
3242
<mwc-list-item
33-
?hasMeta=${this.playing}
43+
?hasMeta=${this.playing || hasBadges}
3444
?selected=${this.selected}
3545
?activated=${this.selected}
3646
class="button ${this.searchHighlight ? 'search-highlight' : ''}"
@@ -57,6 +67,40 @@ class MediaRow extends LitElement {
5767
</div>
5868
<div class="meta-content" slot="meta">
5969
<sonos-playing-bars .show=${this.playing}></sonos-playing-bars>
70+
${hasBadges
71+
? html`<div class="badges">
72+
${showClickableHeart
73+
? html`<div
74+
class="badge-toggle ${this.favoriteLoading ? 'loading' : ''}"
75+
@click=${this.onFavoriteClick}
76+
>
77+
${this.favoriteLoading
78+
? html`<ha-circular-progress indeterminate size="tiny"></ha-circular-progress>`
79+
: html`<ha-svg-icon
80+
class=${this.isFavorite ? 'accent' : ''}
81+
.path=${this.isFavorite ? mdiHeart : mdiHeartOutline}
82+
></ha-svg-icon>`}
83+
</div>`
84+
: this.showFavoriteBadge
85+
? html`<ha-svg-icon class="accent" .path=${mdiHeart}></ha-svg-icon>`
86+
: nothing}
87+
${showClickableLibrary
88+
? html`<div
89+
class="badge-toggle ${this.libraryLoading ? 'loading' : ''}"
90+
@click=${this.onLibraryClick}
91+
>
92+
${this.libraryLoading
93+
? html`<ha-circular-progress indeterminate size="tiny"></ha-circular-progress>`
94+
: html`<ha-svg-icon
95+
class=${this.isInLibrary ? 'accent' : ''}
96+
.path=${mdiBookshelf}
97+
></ha-svg-icon>`}
98+
</div>`
99+
: this.showLibraryBadge
100+
? html`<ha-svg-icon class="accent" .path=${mdiBookshelf}></ha-svg-icon>`
101+
: nothing}
102+
</div>`
103+
: nothing}
60104
<slot></slot>
61105
</div>
62106
</mwc-list-item>
@@ -73,6 +117,20 @@ class MediaRow extends LitElement {
73117
this.dispatchEvent(customEvent('queue-item'));
74118
}
75119

120+
private onFavoriteClick(e: Event) {
121+
e.stopPropagation();
122+
if (!this.favoriteLoading) {
123+
this.dispatchEvent(customEvent('favorite-toggle', { isFavorite: this.isFavorite }));
124+
}
125+
}
126+
127+
private onLibraryClick(e: Event) {
128+
e.stopPropagation();
129+
if (!this.libraryLoading) {
130+
this.dispatchEvent(customEvent('library-toggle', { isInLibrary: this.isInLibrary }));
131+
}
132+
}
133+
76134
protected async firstUpdated(_changedProperties: PropertyValues) {
77135
super.firstUpdated(_changedProperties);
78136
await this.scrollToSelected(_changedProperties);
@@ -162,8 +220,63 @@ class MediaRow extends LitElement {
162220
.meta-content {
163221
display: flex;
164222
align-items: center;
165-
gap: 0.5rem;
166-
margin-left: 0.5rem;
223+
gap: 4px;
224+
padding-inline: 4px;
225+
}
226+
227+
.badges {
228+
display: flex;
229+
align-items: center;
230+
gap: 2px;
231+
}
232+
233+
.badges > *:not(.badge-toggle) {
234+
--mdc-icon-size: 16px;
235+
width: 16px;
236+
height: 16px;
237+
opacity: 0.7;
238+
}
239+
240+
.badges ha-svg-icon.accent {
241+
color: var(--accent-color, #03a9f4);
242+
opacity: 1;
243+
}
244+
245+
.badge-toggle {
246+
cursor: pointer;
247+
display: flex;
248+
align-items: center;
249+
justify-content: center;
250+
width: 20px;
251+
height: 20px;
252+
}
253+
254+
.badge-toggle ha-svg-icon {
255+
--mdc-icon-size: 16px;
256+
width: 16px;
257+
height: 16px;
258+
opacity: 0.7;
259+
}
260+
261+
.badge-toggle:hover ha-svg-icon {
262+
opacity: 1;
263+
}
264+
265+
.badge-toggle.loading {
266+
pointer-events: none;
267+
}
268+
269+
.badge-toggle ha-circular-progress {
270+
--md-circular-progress-size: 14px;
271+
}
272+
273+
mwc-list-item {
274+
--mdc-list-item-meta-size: auto;
275+
overflow: visible;
276+
}
277+
278+
.mdc-deprecated-list-item__meta {
279+
margin-right: 4px;
167280
}
168281
`,
169282
mediaItemTitleStyle,

src/components/player-controls.ts

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { css, html, LitElement, nothing } from 'lit';
2-
import { property } from 'lit/decorators.js';
1+
import { css, html, LitElement, nothing, PropertyValues } from 'lit';
2+
import { property, state } from 'lit/decorators.js';
33
import MediaControlService from '../services/media-control-service';
44
import Store from '../model/store';
55
import { CardConfig, PlayerConfig } from '../types';
66
import {
77
mdiFastForward,
8+
mdiHeart,
9+
mdiHeartOutline,
810
mdiPauseCircle,
911
mdiPlayBoxMultiple,
1012
mdiPlayCircle,
@@ -21,27 +23,45 @@ import MediaBrowseService from '../services/media-browse-service';
2123

2224
class PlayerControls extends LitElement {
2325
@property({ attribute: false }) store!: Store;
26+
@state() private isFavorite: boolean | null = null;
27+
@state() private favoriteLoading = false;
2428
private config!: CardConfig;
2529
private playerConfig!: PlayerConfig;
2630
private activePlayer!: MediaPlayer;
2731
private mediaControlService!: MediaControlService;
2832
private mediaBrowseService!: MediaBrowseService;
2933
private volumePlayer!: MediaPlayer;
3034
private updateMemberVolumes!: boolean;
35+
private lastMediaContentId: string | undefined;
36+
37+
protected willUpdate(changedProperties: PropertyValues): void {
38+
if (changedProperties.has('store')) {
39+
this.config = this.store.config;
40+
this.playerConfig = this.config.player ?? {};
41+
this.activePlayer = this.store.activePlayer;
42+
this.mediaControlService = this.store.mediaControlService;
43+
this.mediaBrowseService = this.store.mediaBrowseService;
44+
45+
const isMusicAssistant = this.store.hassService.musicAssistantService.isMusicAssistantPlayer(this.activePlayer);
46+
const currentMediaContentId = this.activePlayer.attributes.media_content_id;
47+
if (isMusicAssistant && currentMediaContentId !== this.lastMediaContentId) {
48+
this.lastMediaContentId = currentMediaContentId;
49+
this.isFavorite = null;
50+
this.favoriteLoading = false;
51+
this.refreshFavoriteStatus();
52+
}
53+
}
54+
}
3155

3256
render() {
33-
this.config = this.store.config;
34-
this.playerConfig = this.config.player ?? {};
35-
this.activePlayer = this.store.activePlayer;
36-
this.mediaControlService = this.store.mediaControlService;
37-
this.mediaBrowseService = this.store.mediaBrowseService;
3857
const noUpDown = !!this.playerConfig.showVolumeUpAndDownButtons && nothing;
3958
const noFastForwardAndRewind = !!this.playerConfig.showFastForwardAndRewindButtons && nothing;
4059
const noShuffle = !this.playerConfig.hideControlShuffleButton && nothing;
4160
const noPrev = !this.playerConfig.hideControlPrevTrackButton && nothing;
4261
const noNext = !this.playerConfig.hideControlNextTrackButton && nothing;
4362
const noRepeat = !this.playerConfig.hideControlRepeatButton && nothing;
4463
const noBrowse = !!this.playerConfig.showBrowseMediaButton && nothing;
64+
const isMusicAssistant = this.store.hassService.musicAssistantService.isMusicAssistantPlayer(this.activePlayer);
4565

4666
this.volumePlayer = this.getVolumePlayer();
4767
this.updateMemberVolumes = !this.playerConfig.volumeEntityId;
@@ -55,7 +75,21 @@ class PlayerControls extends LitElement {
5575
</style>
5676
<div class="main" id="mediaControls">
5777
<div class="icons ${this.playerConfig.controlsLargeIcons ? 'large-icons' : ''}">
58-
<div class="flex-1"></div>
78+
<div class="flex-1">
79+
${
80+
isMusicAssistant
81+
? html`<ha-icon-button
82+
class="favorite-button ${this.isFavorite ? 'is-favorite' : ''} ${this.favoriteLoading
83+
? 'loading'
84+
: ''}"
85+
@click=${this.toggleFavorite}
86+
.path=${this.isFavorite ? mdiHeart : mdiHeartOutline}
87+
title=${this.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
88+
?disabled=${this.favoriteLoading}
89+
></ha-icon-button>`
90+
: nothing
91+
}
92+
</div>
5993
<ha-icon-button hide=${noUpDown} @click=${this.volDown} .path=${mdiVolumeMinus}></ha-icon-button>
6094
<sonos-shuffle hide=${noShuffle} .store=${this.store}></sonos-shuffle>
6195
<ha-icon-button hide=${noPrev} @click=${this.prev} .path=${mdiSkipPrevious}></ha-icon-button>
@@ -119,6 +153,40 @@ class PlayerControls extends LitElement {
119153
this.activePlayer.attributes.media_position + (this.playerConfig.fastForwardAndRewindStepSizeSeconds || 15),
120154
);
121155

156+
private async refreshFavoriteStatus() {
157+
const songIdAtStart = this.activePlayer.attributes.media_content_id;
158+
const favorite = await this.store.hassService.musicAssistantService.getCurrentSongFavorite(this.activePlayer);
159+
// Only update if song hasn't changed during the async call
160+
if (this.activePlayer.attributes.media_content_id === songIdAtStart) {
161+
this.isFavorite = favorite;
162+
}
163+
}
164+
165+
private toggleFavorite = async () => {
166+
if (this.favoriteLoading) {
167+
return;
168+
}
169+
const songIdAtStart = this.activePlayer.attributes.media_content_id;
170+
this.favoriteLoading = true;
171+
try {
172+
if (this.isFavorite) {
173+
const success = await this.store.hassService.musicAssistantService.unfavoriteCurrentSong(this.activePlayer);
174+
// Only update UI if song hasn't changed
175+
if (success && this.activePlayer.attributes.media_content_id === songIdAtStart) {
176+
this.isFavorite = false;
177+
}
178+
} else {
179+
const success = await this.store.hassService.musicAssistantService.favoriteCurrentSong(this.activePlayer);
180+
// Only update UI if song hasn't changed
181+
if (success && this.activePlayer.attributes.media_content_id === songIdAtStart) {
182+
this.isFavorite = true;
183+
}
184+
}
185+
} finally {
186+
this.favoriteLoading = false;
187+
}
188+
};
189+
122190
static get styles() {
123191
return css`
124192
.main {
@@ -152,6 +220,12 @@ class PlayerControls extends LitElement {
152220
.browse-button {
153221
float: right;
154222
}
223+
.favorite-button.is-favorite {
224+
color: var(--accent-color);
225+
}
226+
.favorite-button.loading {
227+
opacity: 0.5;
228+
}
155229
156230
.large-icons {
157231
margin-bottom: 2rem;

src/editor/schema/common-schema.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ export const ENTITIES_SCHEMA = [
145145
cardType: 'sonos',
146146
selector: { entity: { multiple: true, filter: { domain: 'media_player' } } },
147147
},
148+
{
149+
name: 'useMusicAssistant',
150+
selector: { boolean: {} },
151+
},
148152
{
149153
name: 'showNonSonosPlayers',
150154
help: 'Show all media players, including those that are not on the Sonos platform',
@@ -154,7 +158,7 @@ export const ENTITIES_SCHEMA = [
154158
{
155159
name: 'entityPlatform',
156160
help: 'Show all media players for the selected platform',
157-
type: 'string',
161+
selector: { text: {} },
158162
},
159163
{
160164
name: 'entityNameRegexToReplace',

src/editor/schema/search-schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const mediaTypeOptions = {
44
artist: 'Artist',
55
album: 'Album',
66
playlist: 'Playlist',
7+
radio: 'Radio',
78
};
89

910
export const SEARCH_SCHEMA = [
@@ -30,7 +31,7 @@ export const SEARCH_SCHEMA = [
3031
{
3132
name: 'autoSearchMinChars',
3233
type: 'integer',
33-
help: 'Min characters to trigger auto-search (default: 3)',
34+
help: 'Min characters to trigger auto-search (default: 2)',
3435
},
3536
{
3637
name: 'autoSearchDebounceMs',

0 commit comments

Comments
 (0)