Skip to content

Commit

Permalink
refactor volumes
Browse files Browse the repository at this point in the history
  • Loading branch information
punxaphil committed Mar 3, 2024
1 parent 71ac51f commit db042d9
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 96 deletions.
47 changes: 14 additions & 33 deletions src/components/volume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@ class Volume extends LitElement {
this.config = this.store.config;
this.mediaControlService = this.store.mediaControlService;

const volume = this.getVolume();
const volume = this.player.getVolume();
const max = volume < 20 && this.config.dynamicVolumeSlider ? 30 : 100;

const muteIcon = this.player.isMuted(this.updateMembers) ? mdiVolumeMute : mdiVolumeHigh;
const disabled = this.player.ignoreVolume;
return html`
<div class="volume" slim=${this.slim || nothing}>
<ha-icon-button @click=${this.mute} .path=${muteIcon}> </ha-icon-button>
<ha-icon-button .disabled=${disabled} @click=${this.mute} .path=${muteIcon}> </ha-icon-button>
<div class="volume-slider">
<ha-control-slider .value=${volume} max=${max} @value-changed=${this.volumeChanged}></ha-control-slider>
<ha-control-slider
.value=${volume}
max=${max}
@value-changed=${this.volumeChanged}
.disabled=${disabled}
></ha-control-slider>
<div class="volume-level">
<div style="flex: ${volume}">0%</div>
<div class="percentage">${Math.round(volume)}%</div>
Expand All @@ -38,37 +44,9 @@ class Volume extends LitElement {
`;
}

private getVolume() {
if (this.updateMembers && this.config.adjustVolumeRelativeToMainPlayer) {
const volumes = [
this.player.attributes.volume_level,
...this.player.members.map((m) => m.attributes.volume_level),
];
return (100 * volumes.reduce((a, b) => a + b, 0)) / volumes.length;
} else {
return 100 * this.getVolumeLevelPlayer().attributes.volume_level;
}
}

private getVolumeLevelPlayer() {
let volumeLevelPlayer = this.player;
if (this.updateMembers && this.player.members.length && this.config.entitiesToIgnoreVolumeLevelFor) {
const players = [volumeLevelPlayer, ...volumeLevelPlayer.members];
volumeLevelPlayer =
players.find((p) => {
return !this.config.entitiesToIgnoreVolumeLevelFor?.includes(p.id);
}) ?? volumeLevelPlayer;
}
return volumeLevelPlayer;
}

private async volumeChanged(e: Event) {
const volume = numberFromEvent(e);
return await this.setVolume(volume);
}

private async setVolume(volume: number) {
return await this.mediaControlService.volumeSet(this.player, volume, this.updateMembers);
const newVolume = numberFromEvent(e);
return await this.mediaControlService.volumeSet(this.player, newVolume, this.updateMembers);
}

private async mute() {
Expand All @@ -80,6 +58,9 @@ class Volume extends LitElement {
ha-control-slider {
--control-slider-color: var(--accent-color);
}
ha-control-slider[disabled] {
--control-slider-color: var(--disabled-text-color);
}
*[slim] * {
--control-slider-thickness: 10px;
Expand Down
50 changes: 34 additions & 16 deletions src/model/media-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ export class MediaPlayer {
members: MediaPlayer[];
attributes: HassEntity['attributes'];
private readonly config: CardConfig;
volumePlayer: MediaPlayer;
ignoreVolume: boolean;

constructor(hassEntity: HassEntity, config: CardConfig, mediaPlayerHassEntities?: HassEntity[]) {
this.id = hassEntity.entity_id;
this.config = config;
this.name = this.getEntityName(hassEntity, config);
this.state = hassEntity.state;
this.attributes = hassEntity.attributes;
this.members = mediaPlayerHassEntities ? this.createGroupMembers(hassEntity, mediaPlayerHassEntities) : [];
this.members = mediaPlayerHassEntities ? this.createGroupMembers(hassEntity, mediaPlayerHassEntities) : [this];
this.volumePlayer = this.determineVolumePlayer();
this.ignoreVolume = !!this.config.entitiesToIgnoreVolumeLevelFor?.includes(this.volumePlayer.id);
}

getPlayer(playerId: string) {
return this.id === playerId ? this : this.getMember(playerId);
}

private getMember(playerId: string) {
getMember(playerId: string) {
return this.members.find((member) => member.id === playerId);
}

Expand All @@ -38,10 +38,10 @@ export class MediaPlayer {
isMuted(checkMembers: boolean): boolean {
return this.attributes.is_volume_muted && (!checkMembers || this.members.every((member) => member.isMuted(false)));
}

getCurrentTrack() {
return `${this.attributes.media_artist || ''} - ${this.attributes.media_title || ''}`.replace(/^ - | - $/g, '');
}

private getEntityName(hassEntity: HassEntity, config: CardConfig) {
const name = hassEntity.attributes.friendly_name || '';
if (config.entityNameRegexToReplace) {
Expand All @@ -51,16 +51,34 @@ export class MediaPlayer {
}

private createGroupMembers(mainHassEntity: HassEntity, mediaPlayerHassEntities: HassEntity[]): MediaPlayer[] {
const groupPlayerIds = getGroupPlayerIds(mainHassEntity);
return mediaPlayerHassEntities
.filter(
(hassEntity) =>
groupPlayerIds.includes(hassEntity.entity_id) && mainHassEntity.entity_id !== hassEntity.entity_id,
)
.map((hassEntity) => new MediaPlayer(hassEntity, this.config));
return getGroupPlayerIds(mainHassEntity).reduce((players: MediaPlayer[], id) => {
const hassEntity = mediaPlayerHassEntities.find((hassEntity) => hassEntity.entity_id === id);
return hassEntity ? [...players, new MediaPlayer(hassEntity, this.config)] : players;
}, []);
}

private determineVolumePlayer() {
let find;
if (this.members.length > 1 && this.config.entitiesToIgnoreVolumeLevelFor) {
find = this.members.find((p) => {
return !this.config.entitiesToIgnoreVolumeLevelFor?.includes(p.id);
});
}
return find ?? this;
}

getVolume() {
if (this.members.length > 1 && this.config.adjustVolumeRelativeToMainPlayer) {
return this.getAverageVolume();
} else {
return 100 * this.volumePlayer.attributes.volume_level;
}
}

isGrouped() {
return this.members.length > 0;
private getAverageVolume() {
const volumes = this.members
.filter((m) => !this.config.entitiesToIgnoreVolumeLevelFor?.includes(m.id))
.map((m) => m.attributes.volume_level);
return (100 * volumes.reduce((a, b) => a + b, 0)) / volumes.length;
}
}
7 changes: 2 additions & 5 deletions src/model/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ export default class Store {
const mediaPlayerHassEntities = this.getMediaPlayerHassEntities(this.hass);
this.allGroups = this.createPlayerGroups(mediaPlayerHassEntities);
this.allMediaPlayers = this.allGroups
.reduce(
(previousValue: MediaPlayer[], currentValue) => [...previousValue, currentValue, ...currentValue.members],
[],
)
.reduce((previousValue: MediaPlayer[], currentValue) => [...previousValue, ...currentValue.members], [])
.sort((a, b) => a.name.localeCompare(b.name));
this.activePlayer = this.determineActivePlayer(activePlayerId);
this.hassService = new HassService(this.hass, currentSection, card, config);
Expand Down Expand Up @@ -169,7 +166,7 @@ export default class Store {
private determineActivePlayer(activePlayerId?: string): MediaPlayer {
const playerId = activePlayerId || this.config.entityId || this.getActivePlayerFromUrl();
return (
this.allGroups.find((group) => group.getPlayer(playerId) !== undefined) ||
this.allGroups.find((group) => group.getMember(playerId) !== undefined) ||
this.allGroups.find((group) => group.isPlaying()) ||
this.allGroups[0]
);
Expand Down
18 changes: 14 additions & 4 deletions src/sections/volumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ class Volumes extends LitElement {

const members = this.activePlayer.members;
return html`
${when(members.length, () => this.volumeWithName(this.activePlayer))}
${[this.activePlayer, ...members].map((member) => this.volumeWithName(member, false))}
${when(members.length > 1, () => this.volumeWithName(this.activePlayer))}
${members.map((member) => this.volumeWithName(member, false))}
`;
}

Expand All @@ -48,9 +48,19 @@ class Volumes extends LitElement {
<div class="volume-name-text">${name}</div>
</div>
<div class="slider-row">
<ha-icon-button hide=${noUpDown} @click=${volDown} .path=${mdiVolumeMinus}></ha-icon-button>
<ha-icon-button
.disabled=${player.ignoreVolume}
hide=${noUpDown}
@click=${volDown}
.path=${mdiVolumeMinus}
></ha-icon-button>
<sonos-volume .store=${this.store} .player=${player} .updateMembers=${updateMembers}></sonos-volume>
<ha-icon-button hide=${noUpDown} @click=${volUp} .path=${mdiVolumePlus}></ha-icon-button>
<ha-icon-button
.disabled=${player.ignoreVolume}
hide=${noUpDown}
@click=${volUp}
.path=${mdiVolumePlus}
></ha-icon-button>
<ha-icon-button
hide=${updateMembers || nothing}
@click=${() => this.toggleShowSwitches(player)}
Expand Down
111 changes: 75 additions & 36 deletions src/services/media-control-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CardConfig, MediaPlayerItem, PredefinedGroup } from '../types';
import { CalculateVolume, CardConfig, MediaPlayerItem, PredefinedGroup } from '../types';
import HassService from './hass-service';
import { MediaPlayer } from '../model/media-player';

Expand Down Expand Up @@ -28,7 +28,7 @@ export default class MediaControlService {
for (const pgp of pg.entities) {
const volume = pgp.volume ?? pg.volume;
if (volume) {
await this.volumeSet(pgp.player, volume, false);
await this.volumeSetSinglePlayer(pgp.player, volume);
}
if (pg.unmuteWhenGrouped) {
await this.setVolumeMute(pgp.player, false, false);
Expand All @@ -39,50 +39,90 @@ export default class MediaControlService {
}
}

async volumeDown(mediaPlayer: MediaPlayer, updateMembers = true) {
async volumeDown(mainPlayer: MediaPlayer, updateMembers = true) {
await this.volumeStep(mainPlayer, updateMembers, this.getStepDownVolume, 'volume_down');
}

async volumeUp(mainPlayer: MediaPlayer, updateMembers = true) {
await this.volumeStep(mainPlayer, updateMembers, this.getStepUpVolume, 'volume_up');
}

private async volumeStep(
mainPlayer: MediaPlayer,
updateMembers: boolean,
calculateVolume: (member: MediaPlayer, volumeStepSize: number) => number,
stepDirection: string,
) {
if (this.config.volumeStepSize) {
const volume = mediaPlayer.attributes.volume_level * 100;
const newVolume = Math.max(0, volume - this.config.volumeStepSize);
await this.volumeSet(mediaPlayer, newVolume, updateMembers);
return;
await this.volumeWithStepSize(mainPlayer, updateMembers, this.config.volumeStepSize, calculateVolume);
} else {
await this.hassService.callMediaService('volume_down', { entity_id: mediaPlayer.id });
if (updateMembers) {
for (const member of mediaPlayer.members) {
await this.hassService.callMediaService('volume_down', { entity_id: member.id });
}
await this.volumeDefaultStep(mainPlayer, updateMembers, stepDirection);
}
}

private async volumeWithStepSize(
mainPlayer: MediaPlayer,
updateMembers: boolean,
volumeStepSize: number,
calculateVolume: CalculateVolume,
) {
for (const member of mainPlayer.members) {
if (mainPlayer.id === member.id || updateMembers) {
const newVolume = calculateVolume(member, volumeStepSize);
await this.volumeSetSinglePlayer(member, newVolume);
}
}
}

async volumeUp(mediaPlayer: MediaPlayer, updateMembers = true) {
if (this.config.volumeStepSize) {
const volume = mediaPlayer.attributes.volume_level * 100;
const newVolume = Math.min(100, volume + this.config.volumeStepSize);
await this.volumeSet(mediaPlayer, newVolume, updateMembers);
} else {
await this.hassService.callMediaService('volume_up', { entity_id: mediaPlayer.id });
if (updateMembers) {
for (const member of mediaPlayer.members) {
await this.hassService.callMediaService('volume_up', { entity_id: member.id });
private getStepDownVolume(member: MediaPlayer, volumeStepSize: number) {
return Math.max(0, member.getVolume() - volumeStepSize);
}

private getStepUpVolume(member: MediaPlayer, stepSize: number) {
return Math.min(100, member.getVolume() + stepSize);
}

private async volumeDefaultStep(mainPlayer: MediaPlayer, updateMembers: boolean, stepDirection: string) {
for (const member of mainPlayer.members) {
if (mainPlayer.id === member.id || updateMembers) {
if (!member.ignoreVolume) {
await this.hassService.callMediaService(stepDirection, { entity_id: member.id });
}
}
}
}

async volumeSet(mediaPlayer: MediaPlayer, volume: number, updateMembers = true) {
let volume_level = volume / 100;

await this.hassService.callMediaService('volume_set', { entity_id: mediaPlayer.id, volume_level: volume_level });
const relativeVolumeChange = volume_level - mediaPlayer.attributes.volume_level;
async volumeSet(player: MediaPlayer, volume: number, updateMembers: boolean) {
if (updateMembers) {
for (const member of mediaPlayer.members) {
if (this.config.adjustVolumeRelativeToMainPlayer) {
volume_level = member.attributes.volume_level + relativeVolumeChange;
volume_level = Math.min(1, Math.max(0, volume_level));
return await this.volumeSetGroup(player, volume);
} else {
return await this.volumeSetSinglePlayer(player, volume);
}
}
private async volumeSetGroup(player: MediaPlayer, volumePercent: number) {
let relativeVolumeChange: number | undefined;
if (this.config.adjustVolumeRelativeToMainPlayer) {
relativeVolumeChange = volumePercent - player.getVolume();
}

await Promise.all(
player.members.map((member) => {
let memberVolume = volumePercent;
if (relativeVolumeChange !== undefined) {
if (this.config.adjustVolumeRelativeToMainPlayer) {
memberVolume = member.getVolume() + relativeVolumeChange;
memberVolume = Math.min(100, Math.max(0, memberVolume));
}
}
await this.hassService.callMediaService('volume_set', { entity_id: member.id, volume_level });
}
this.volumeSetSinglePlayer(member, memberVolume);
}),
);
}

async volumeSetSinglePlayer(player: MediaPlayer, volumePercent: number) {
if (!player.ignoreVolume) {
const volume = volumePercent / 100;
await this.hassService.callMediaService('volume_set', { entity_id: player.id, volume_level: volume });
}
}

Expand All @@ -92,9 +132,8 @@ export default class MediaControlService {
}

async setVolumeMute(mediaPlayer: MediaPlayer, muteVolume: boolean, updateMembers = true) {
await this.hassService.callMediaService('volume_mute', { entity_id: mediaPlayer.id, is_volume_muted: muteVolume });
if (updateMembers) {
for (const member of mediaPlayer.members) {
for (const member of mediaPlayer.members) {
if (mediaPlayer.id === member.id || updateMembers) {
await this.hassService.callMediaService('volume_mute', { entity_id: member.id, is_volume_muted: muteVolume });
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export enum Section {

export type ConfigPredefinedGroupPlayer = PredefinedGroupPlayer<string>;
export type ConfigPredefinedGroup = PredefinedGroup<string | ConfigPredefinedGroupPlayer>;
export type CalculateVolume = (member: MediaPlayer, volumeStepSize: number) => number;

export interface CardConfig extends LovelaceCardConfig {
sections?: Section[];
showVolumeUpAndDownButtons?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ACTIVE_PLAYER_EVENT, ACTIVE_PLAYER_EVENT_INTERNAL } from '../constants'
import { MediaPlayer } from '../model/media-player';

export function getSpeakerList(mainPlayer: MediaPlayer, predefinedGroups: PredefinedGroup[] = []) {
const playerIds = [mainPlayer.id, ...mainPlayer.members.map((member) => member.id)].sort();
const playerIds = mainPlayer.members.map((member) => member.id).sort();
if (predefinedGroups?.length) {
const found = predefinedGroups.find(
(pg) =>
Expand All @@ -17,7 +17,7 @@ export function getSpeakerList(mainPlayer: MediaPlayer, predefinedGroups: Predef
return found.name;
}
}
return [mainPlayer.name, ...mainPlayer.members.map((member) => member.name)].join(' + ');
return mainPlayer.members.map((member) => member.name).join(' + ');
}

export function dispatchActivePlayerId(playerId: string, config: CardConfig, element: Element) {
Expand Down

0 comments on commit db042d9

Please sign in to comment.