From ea1b7b9decb31bcf57656bf3d57e026953a3801f Mon Sep 17 00:00:00 2001 From: uptimeZERO_ Date: Wed, 21 Jan 2026 19:28:32 +0000 Subject: [PATCH] Media player fixes (#29075) * aligning ui of dialog and media bar * refactored media progress logic to be reusable * updating track times to be consistent with music assistant * WIP aligning volume slider with music assistant * migrating to ha-dropdown * showing volume tooltip on touch devices * Fixed volume slider going to 100 randomly * Added scrolling support * Refactored volume control logic --- src/common/util/media-progress.ts | 19 ++ src/common/util/volume-slider.ts | 186 ++++++++++++ src/data/media-player.ts | 17 +- .../controls/more-info-media_player.ts | 136 +++++++-- .../media-browser/ha-bar-media-player.ts | 281 +++++++++++++----- 5 files changed, 535 insertions(+), 104 deletions(-) create mode 100644 src/common/util/media-progress.ts create mode 100644 src/common/util/volume-slider.ts diff --git a/src/common/util/media-progress.ts b/src/common/util/media-progress.ts new file mode 100644 index 0000000000..c44428c0b8 --- /dev/null +++ b/src/common/util/media-progress.ts @@ -0,0 +1,19 @@ +export const startMediaProgressInterval = ( + interval: number | undefined, + callback: () => void, + intervalMs = 1000 +): number => { + if (interval) { + return interval; + } + return window.setInterval(callback, intervalMs); +}; + +export const stopMediaProgressInterval = ( + interval: number | undefined +): number | undefined => { + if (interval) { + clearInterval(interval); + } + return undefined; +}; diff --git a/src/common/util/volume-slider.ts b/src/common/util/volume-slider.ts new file mode 100644 index 0000000000..84921cbd9c --- /dev/null +++ b/src/common/util/volume-slider.ts @@ -0,0 +1,186 @@ +import type { HaSlider } from "../../components/ha-slider"; + +interface VolumeSliderControllerOptions { + getSlider: () => HaSlider | undefined; + step: number; + onSetVolume: (value: number) => void; + onSetVolumeDebounced?: (value: number) => void; + onValueUpdated?: (value: number) => void; +} + +export class VolumeSliderController { + private _touchStartX = 0; + + private _touchStartY = 0; + + private _touchStartValue = 0; + + private _touchDragging = false; + + private _touchScrolling = false; + + private _dragging = false; + + private _lastValue = 0; + + private _options: VolumeSliderControllerOptions; + + constructor(options: VolumeSliderControllerOptions) { + this._options = options; + } + + public get isInteracting(): boolean { + return this._touchDragging || this._dragging; + } + + public setStep(step: number): void { + this._options.step = step; + } + + public handleInput = (ev: Event): void => { + ev.stopPropagation(); + const value = Number((ev.target as HaSlider).value); + this._dragging = true; + this._updateValue(value); + this._options.onSetVolumeDebounced?.(value); + }; + + public handleChange = (ev: Event): void => { + ev.stopPropagation(); + const value = Number((ev.target as HaSlider).value); + this._dragging = false; + this._updateValue(value); + this._options.onSetVolume(value); + }; + + public handleTouchStart = (ev: TouchEvent): void => { + ev.stopPropagation(); + const touch = ev.touches[0]; + this._touchStartX = touch.clientX; + this._touchStartY = touch.clientY; + this._touchStartValue = this._getSliderValue(); + this._touchDragging = false; + this._touchScrolling = false; + this._showTooltip(); + }; + + public handleTouchMove = (ev: TouchEvent): void => { + if (this._touchScrolling) { + return; + } + const touch = ev.touches[0]; + const deltaX = touch.clientX - this._touchStartX; + const deltaY = touch.clientY - this._touchStartY; + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + + if (!this._touchDragging) { + if (absDeltaY > 10 && absDeltaY > absDeltaX * 2) { + this._touchScrolling = true; + return; + } + if (absDeltaX > 8) { + this._touchDragging = true; + } + } + + if (this._touchDragging) { + ev.preventDefault(); + const newValue = this._getVolumeFromTouch(touch.clientX); + this._updateValue(newValue); + } + }; + + public handleTouchEnd = (ev: TouchEvent): void => { + if (this._touchScrolling) { + this._touchScrolling = false; + this._hideTooltip(); + return; + } + + const touch = ev.changedTouches[0]; + if (!this._touchDragging) { + const tapValue = this._getVolumeFromTouch(touch.clientX); + const delta = + tapValue > this._touchStartValue + ? this._options.step + : -this._options.step; + const newValue = this._roundVolumeValue(this._touchStartValue + delta); + this._updateValue(newValue); + this._options.onSetVolume(newValue); + } else { + const finalValue = this._getVolumeFromTouch(touch.clientX); + this._updateValue(finalValue); + this._options.onSetVolume(finalValue); + } + + this._touchDragging = false; + this._dragging = false; + this._hideTooltip(); + }; + + public handleTouchCancel = (): void => { + this._touchDragging = false; + this._touchScrolling = false; + this._dragging = false; + this._updateValue(this._touchStartValue); + this._hideTooltip(); + }; + + public handleWheel = (ev: WheelEvent): void => { + ev.preventDefault(); + ev.stopPropagation(); + const direction = ev.deltaY > 0 ? -1 : 1; + const currentValue = this._getSliderValue(); + const newValue = this._roundVolumeValue( + currentValue + direction * this._options.step + ); + this._updateValue(newValue); + this._options.onSetVolume(newValue); + }; + + private _getVolumeFromTouch(clientX: number): number { + const slider = this._options.getSlider(); + if (!slider) { + return 0; + } + const rect = slider.getBoundingClientRect(); + const x = Math.min(Math.max(clientX - rect.left, 0), rect.width); + const percentage = (x / rect.width) * 100; + return this._roundVolumeValue(percentage); + } + + private _roundVolumeValue(value: number): number { + return Math.min( + Math.max(Math.round(value / this._options.step) * this._options.step, 0), + 100 + ); + } + + private _getSliderValue(): number { + const slider = this._options.getSlider(); + if (slider) { + return Number(slider.value); + } + return this._lastValue; + } + + private _updateValue(value: number): void { + this._lastValue = value; + this._options.onValueUpdated?.(value); + const slider = this._options.getSlider(); + if (slider) { + slider.value = value; + } + } + + private _showTooltip(): void { + const slider = this._options.getSlider() as any; + slider?.showTooltip?.(); + } + + private _hideTooltip(): void { + const slider = this._options.getSlider() as any; + slider?.hideTooltip?.(); + } +} diff --git a/src/data/media-player.ts b/src/data/media-player.ts index 50d414d8b5..4075301d41 100644 --- a/src/data/media-player.ts +++ b/src/data/media-player.ts @@ -423,12 +423,17 @@ export const formatMediaTime = (seconds: number | undefined): string => { return ""; } - let secondsString = new Date(seconds * 1000).toISOString(); - secondsString = - seconds > 3600 - ? secondsString.substring(11, 16) - : secondsString.substring(14, 19); - return secondsString.replace(/^0+/, "").padStart(4, "0"); + const totalSeconds = Math.max(0, Math.floor(seconds)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + const pad = (value: number) => value.toString().padStart(2, "0"); + + if (hours > 0) { + return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + } + + return `${pad(minutes)}:${pad(secs)}`; }; export const cleanupMediaTitle = (title?: string): string | undefined => { diff --git a/src/dialogs/more-info/controls/more-info-media_player.ts b/src/dialogs/more-info/controls/more-info-media_player.ts index e181fb9608..d79b058701 100644 --- a/src/dialogs/more-info/controls/more-info-media_player.ts +++ b/src/dialogs/more-info/controls/more-info-media_player.ts @@ -14,9 +14,14 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, query } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; -import { formatDurationDigital } from "../../../common/datetime/format_duration"; import { stateActive } from "../../../common/entity/state_active"; import { supportsFeature } from "../../../common/entity/supports-feature"; +import { debounce } from "../../../common/util/debounce"; +import { + startMediaProgressInterval, + stopMediaProgressInterval, +} from "../../../common/util/media-progress"; +import { VolumeSliderController } from "../../../common/util/volume-slider"; import "../../../components/chips/ha-assist-chip"; import "../../../components/ha-button"; import "../../../components/ha-icon-button"; @@ -38,6 +43,7 @@ import { cleanupMediaTitle, computeMediaControls, computeMediaDescription, + formatMediaTime, handleMediaControlClick, MediaPlayerEntityFeature, mediaPlayerPlayMedia, @@ -54,6 +60,34 @@ class MoreInfoMediaPlayer extends LitElement { @query("#position-slider") private _positionSlider?: HaSlider; + @query(".volume-slider") + private _volumeSlider?: HaSlider; + + private _progressInterval?: number; + + private _volumeStep = 2; + + private _debouncedVolumeSet = debounce((value: number) => { + this._setVolume(value); + }, 100); + + private _volumeController = new VolumeSliderController({ + getSlider: () => this._volumeSlider, + step: this._volumeStep, + onSetVolume: (value) => this._setVolume(value), + onSetVolumeDebounced: (value) => this._debouncedVolumeSet(value), + }); + + public connectedCallback(): void { + super.connectedCallback(); + this._syncProgressInterval(); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + this._clearProgressInterval(); + } + protected firstUpdated(_changedProperties: PropertyValues) { if (this._positionSlider) { this._positionSlider.valueFormatter = (value: number) => @@ -62,14 +96,7 @@ class MoreInfoMediaPlayer extends LitElement { } private _formatDuration(duration: number) { - const hours = Math.floor(duration / 3600); - const minutes = Math.floor((duration % 3600) / 60); - const seconds = Math.floor(duration % 60); - return formatDurationDigital(this.hass.locale, { - hours, - minutes, - seconds, - })!; + return formatMediaTime(duration); } protected _renderVolumeControl() { @@ -139,13 +166,25 @@ class MoreInfoMediaPlayer extends LitElement { ${!supportsMute ? html`` : nothing} - +
+ +
` : nothing} @@ -261,17 +300,17 @@ class MoreInfoMediaPlayer extends LitElement { const stateObj = this.stateObj; const controls = computeMediaControls(stateObj, true); - const coverUrl = + const coverUrlRaw = stateObj.attributes.entity_picture_local || stateObj.attributes.entity_picture || ""; + const coverUrl = coverUrlRaw ? this.hass.hassUrl(coverUrlRaw) : ""; const playerObj = new HassMediaPlayerEntity(this.hass, this.stateObj); const position = Math.max(Math.floor(playerObj.currentProgress || 0), 0); const duration = Math.max(stateObj.attributes.media_duration || 0, 0); - const remaining = Math.max(duration - position, 0); - const remainingFormatted = this._formatDuration(remaining); const positionFormatted = this._formatDuration(position); + const durationFormatted = this._formatDuration(duration); const primaryTitle = cleanupMediaTitle(stateObj.attributes.media_title); const secondaryTitle = computeMediaDescription(stateObj); const turnOn = controls?.find((c) => c.action === "turn_on"); @@ -331,8 +370,12 @@ class MoreInfoMediaPlayer extends LitElement { ?disabled=${!stateActive(stateObj) || !supportsFeature(stateObj, MediaPlayerEntityFeature.SEEK)} > - ${positionFormatted} - ${remainingFormatted} + ${positionFormatted} + ${durationFormatted} ` @@ -531,6 +574,16 @@ class MoreInfoMediaPlayer extends LitElement { margin-left: var(--ha-space-2); } + .volume-slider-container { + width: 100%; + } + + @media (pointer: coarse) { + .volume-slider { + pointer-events: none; + } + } + .volume ha-svg-icon { padding: var(--ha-space-1); height: 16px; @@ -568,6 +621,10 @@ class MoreInfoMediaPlayer extends LitElement { color: var(--secondary-text-color); } + .position-time { + margin-top: var(--ha-space-2); + } + .media-info-row { display: flex; flex-direction: column; @@ -622,6 +679,39 @@ class MoreInfoMediaPlayer extends LitElement { ); } + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + if (changedProps.has("stateObj")) { + this._syncProgressInterval(); + } + } + + private _syncProgressInterval(): void { + if (this._shouldUpdateProgress()) { + this._progressInterval = startMediaProgressInterval( + this._progressInterval, + () => this.requestUpdate() + ); + return; + } + this._clearProgressInterval(); + } + + private _clearProgressInterval(): void { + this._progressInterval = stopMediaProgressInterval(this._progressInterval); + } + + private _shouldUpdateProgress(): boolean { + const stateObj = this.stateObj; + return ( + !!stateObj && + stateObj.state === "playing" && + Number(stateObj.attributes.media_duration) > 0 && + "media_position" in stateObj.attributes && + "media_position_updated_at" in stateObj.attributes + ); + } + private _toggleMute() { this.hass!.callService("media_player", "volume_mute", { entity_id: this.stateObj!.entity_id, @@ -629,10 +719,10 @@ class MoreInfoMediaPlayer extends LitElement { }); } - private _selectedValueChanged(e: Event): void { + private _setVolume(value: number) { this.hass!.callService("media_player", "volume_set", { entity_id: this.stateObj!.entity_id, - volume_level: (e.target as any).value / 100, + volume_level: value / 100, }); } diff --git a/src/panels/media-browser/ha-bar-media-player.ts b/src/panels/media-browser/ha-bar-media-player.ts index 338f0f2f7d..738749bef5 100644 --- a/src/panels/media-browser/ha-bar-media-player.ts +++ b/src/panels/media-browser/ha-bar-media-player.ts @@ -1,5 +1,3 @@ -import "@material/mwc-linear-progress/mwc-linear-progress"; -import type { LinearProgress } from "@material/mwc-linear-progress/mwc-linear-progress"; import { mdiChevronDown, mdiMonitor, @@ -20,11 +18,16 @@ import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateName } from "../../common/entity/compute_state_name"; import { supportsFeature } from "../../common/entity/supports-feature"; import { debounce } from "../../common/util/debounce"; +import { + startMediaProgressInterval, + stopMediaProgressInterval, +} from "../../common/util/media-progress"; +import { VolumeSliderController } from "../../common/util/volume-slider"; import "../../components/ha-button"; -import "../../components/ha-button-menu"; import "../../components/ha-domain-icon"; +import "../../components/ha-dropdown"; +import "../../components/ha-dropdown-item"; import "../../components/ha-icon-button"; -import "../../components/ha-list-item"; import "../../components/ha-slider"; import "../../components/ha-spinner"; import "../../components/ha-state-icon"; @@ -50,6 +53,7 @@ import type { ResolvedMediaSource } from "../../data/media_source"; import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../types"; +import type { HaSlider } from "../../components/ha-slider"; import "../lovelace/components/hui-marquee"; import { BrowserMediaPlayer, @@ -70,20 +74,40 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { @property({ type: Boolean, reflect: true }) public narrow = false; - @query("mwc-linear-progress") private _progressBar?: LinearProgress; + @query(".progress-slider") private _progressBar?: HaSlider; @query("#CurrentProgress") private _currentProgress?: HTMLElement; + @query(".volume-slider") private _volumeSlider?: HaSlider; + @state() private _marqueeActive = false; @state() private _newMediaExpected = false; @state() private _browserPlayer?: BrowserMediaPlayer; + private _volumeValue = 0; + private _progressInterval?: number; private _browserPlayerVolume = 0.8; + private _volumeStep = 2; + + private _debouncedVolumeSet = debounce((value: number) => { + this._setVolume(value); + }, 100); + + private _volumeController = new VolumeSliderController({ + getSlider: () => this._volumeSlider, + step: this._volumeStep, + onSetVolume: (value) => this._setVolume(value), + onSetVolumeDebounced: (value) => this._debouncedVolumeSet(value), + onValueUpdated: (value) => { + this._volumeValue = value; + }, + }); + public connectedCallback(): void { super.connectedCallback(); @@ -94,23 +118,20 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { } if ( - !this._progressInterval && this._showProgressBar && - stateObj.state === "playing" + stateObj.state === "playing" && + !this._progressInterval ) { - this._progressInterval = window.setInterval( - () => this._updateProgressBar(), - 1000 + this._progressInterval = startMediaProgressInterval( + this._progressInterval, + () => this._updateProgressBar() ); } } public disconnectedCallback(): void { super.disconnectedCallback(); - if (this._progressInterval) { - clearInterval(this._progressInterval); - this._progressInterval = undefined; - } + this._progressInterval = stopMediaProgressInterval(this._progressInterval); this._tearDownBrowserPlayer(); } @@ -174,7 +195,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { const stateObj = this._stateObj; if (!stateObj) { - return this._renderChoosePlayer(stateObj); + return this._renderChoosePlayer(stateObj, this._volumeValue); } const controls: ControlButton[] | undefined = !this.narrow @@ -214,7 +235,6 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { const mediaArt = stateObj.attributes.entity_picture_local || stateObj.attributes.entity_picture; - return html`
` + ? html`` : html`
- +
${mediaDuration}
`} `}
- ${this._renderChoosePlayer(stateObj)} + ${this._renderChoosePlayer(stateObj, this._volumeValue)} `; } - private _renderChoosePlayer(stateObj: MediaPlayerEntity | undefined) { + private _renderChoosePlayer( + stateObj: MediaPlayerEntity | undefined, + volumeValue: number + ) { const isBrowser = this.entityId === BROWSER_PLAYER; return html`
@@ -294,26 +348,42 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { stateObj && supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET) ? html` - + - - - + + +
+ ` : "" } - + ${ this.narrow ? html` @@ -342,26 +412,24 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { ` } - ${this.hass.localize("ui.components.media-browser.web-browser")} - + ${this._mediaPlayerEntities.map( (source) => html` - ${computeStateName(source)} - + ` )} - + @@ -401,6 +469,9 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { ) { this._newMediaExpected = false; } + if (changedProps.has("hass")) { + this._updateVolumeValueFromState(this._stateObj); + } } protected updated(changedProps: PropertyValues) { @@ -419,23 +490,25 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { const stateObj = this._stateObj; - this._updateProgressBar(); + if (this.entityId === BROWSER_PLAYER) { + this._updateVolumeValueFromState(stateObj); + } - if ( - !this._progressInterval && - this._showProgressBar && - stateObj?.state === "playing" - ) { - this._progressInterval = window.setInterval( - () => this._updateProgressBar(), - 1000 + this._updateProgressBar(); + this._syncVolumeSlider(); + + if (this._showProgressBar && stateObj?.state === "playing") { + this._progressInterval = startMediaProgressInterval( + this._progressInterval, + () => this._updateProgressBar() ); } else if ( this._progressInterval && (!this._showProgressBar || stateObj?.state !== "playing") ) { - clearInterval(this._progressInterval); - this._progressInterval = undefined; + this._progressInterval = stopMediaProgressInterval( + this._progressInterval + ); } } @@ -489,25 +562,45 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { private _updateProgressBar(): void { const stateObj = this._stateObj; - if (!this._progressBar || !this._currentProgress || !stateObj) { + if (!this._progressBar || !stateObj) { return; } if (!stateObj.attributes.media_duration) { - this._progressBar.progress = 0; - this._currentProgress.innerHTML = ""; + this._progressBar.value = 0; + if (this._currentProgress) { + this._currentProgress.innerHTML = ""; + } return; } const currentProgress = getCurrentProgress(stateObj); - this._progressBar.progress = - currentProgress / stateObj.attributes.media_duration; + this._progressBar.max = stateObj.attributes.media_duration; + this._progressBar.value = currentProgress; if (this._currentProgress) { this._currentProgress.innerHTML = formatMediaTime(currentProgress); } } + private _updateVolumeValueFromState(stateObj?: MediaPlayerEntity): void { + if (!stateObj) { + return; + } + const volumeLevel = stateObj.attributes.volume_level; + if (typeof volumeLevel !== "number" || !Number.isFinite(volumeLevel)) { + return; + } + this._volumeValue = Math.round(volumeLevel * 100); + } + + private _syncVolumeSlider(): void { + if (!this._volumeSlider || this._volumeController.isInteracting) { + return; + } + this._volumeSlider.value = this._volumeValue; + } + private _handleControlClick(e: MouseEvent): void { const action = (e.currentTarget! as HTMLElement).getAttribute("action")!; @@ -526,6 +619,18 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { } } + private _handleMediaSeekChanged(e: Event): void { + if (this.entityId === BROWSER_PLAYER || !this._stateObj) { + return; + } + + const newValue = (e.target as HaSlider).value; + this.hass.callService("media_player", "media_seek", { + entity_id: this._stateObj.entity_id, + seek_position: newValue, + }); + } + private _marqueeMouseOver(): void { if (!this._marqueeActive) { this._marqueeActive = true; @@ -538,20 +643,19 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { } } - private _selectPlayer(ev: CustomEvent): void { - const entityId = (ev.currentTarget as any).player; + private _handlePlayerSelect(ev: CustomEvent): void { + const entityId = (ev.detail.item as any).value; fireEvent(this, "player-picked", { entityId }); } - private async _handleVolumeChange(ev) { - ev.stopPropagation(); - const value = Number(ev.target.value) / 100; + private _setVolume(value: number) { + const volume = value / 100; if (this._browserPlayer) { - this._browserPlayerVolume = value; - this._browserPlayer.setVolume(value); - } else { - await setMediaPlayerVolume(this.hass, this.entityId, value); + this._browserPlayerVolume = volume; + this._browserPlayer.setVolume(volume); + return; } + setMediaPlayerVolume(this.hass, this.entityId, volume); } static styles = css` @@ -570,10 +674,11 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { margin-left: var(--safe-area-inset-left); } - mwc-linear-progress { + ha-slider { width: 100%; - padding: 0 4px; - --mdc-theme-primary: var(--secondary-text-color); + min-width: 100%; + --ha-slider-thumb-color: var(--primary-color); + --ha-slider-indicator-color: var(--primary-color); } ha-button-menu ha-button[slot="trigger"] { @@ -611,6 +716,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { justify-content: flex-end; align-items: center; padding: 16px; + gap: var(--ha-space-2); } .controls { @@ -633,10 +739,35 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { align-items: center; } - mwc-linear-progress[wide] { + .progress > div:first-child { + margin-right: var(--ha-space-2); + } + + .progress > div:last-child { + margin-left: var(--ha-space-2); + } + + .progress ha-slider { margin: 0 4px; } + ha-dropdown.volume-menu::part(menu) { + width: 220px; + max-width: 220px; + overflow: visible; + padding: 15px 15px; + } + + .volume-slider-container { + width: 100%; + } + + @media (pointer: coarse) { + .volume-slider { + pointer-events: none; + } + } + .media-info { text-overflow: ellipsis; white-space: nowrap; @@ -700,14 +831,14 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) { justify-content: flex-end; } - :host([narrow]) mwc-linear-progress { - padding: 0; + :host([narrow]) ha-slider { position: absolute; - top: -4px; + top: -6px; left: 0; + right: 0; } - ha-list-item[selected] { + ha-dropdown-item.selected { font-weight: var(--ha-font-weight-bold); } `;