mirror of
https://github.com/home-assistant/frontend.git
synced 2026-02-15 07:25:54 +00:00
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
This commit is contained in:
19
src/common/util/media-progress.ts
Normal file
19
src/common/util/media-progress.ts
Normal file
@@ -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;
|
||||
};
|
||||
186
src/common/util/volume-slider.ts
Normal file
186
src/common/util/volume-slider.ts
Normal file
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>`
|
||||
: nothing}
|
||||
<ha-slider
|
||||
labeled
|
||||
id="input"
|
||||
.value=${Number(this.stateObj.attributes.volume_level) *
|
||||
100}
|
||||
@change=${this._selectedValueChanged}
|
||||
></ha-slider>
|
||||
<div
|
||||
class="volume-slider-container"
|
||||
@touchstart=${this._volumeController.handleTouchStart}
|
||||
@touchmove=${this._volumeController.handleTouchMove}
|
||||
@touchend=${this._volumeController.handleTouchEnd}
|
||||
@touchcancel=${this._volumeController.handleTouchCancel}
|
||||
@wheel=${this._volumeController.handleWheel}
|
||||
>
|
||||
<ha-slider
|
||||
class="volume-slider"
|
||||
labeled
|
||||
id="input"
|
||||
.value=${Number(this.stateObj.attributes.volume_level) *
|
||||
100}
|
||||
.step=${this._volumeStep}
|
||||
@input=${this._volumeController.handleInput}
|
||||
@change=${this._volumeController.handleChange}
|
||||
></ha-slider>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -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)}
|
||||
>
|
||||
<span slot="reference">${positionFormatted}</span>
|
||||
<span slot="reference">${remainingFormatted}</span>
|
||||
<span class="position-time" slot="reference"
|
||||
>${positionFormatted}</span
|
||||
>
|
||||
<span class="position-time" slot="reference"
|
||||
>${durationFormatted}</span
|
||||
>
|
||||
</ha-slider>
|
||||
</div>
|
||||
`
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class=${classMap({
|
||||
@@ -271,21 +291,55 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
||||
${stateObj.attributes.media_duration === Infinity
|
||||
? nothing
|
||||
: this.narrow
|
||||
? html`<mwc-linear-progress></mwc-linear-progress>`
|
||||
? html`<ha-slider
|
||||
class="progress-slider"
|
||||
min="0"
|
||||
max=${stateObj.attributes.media_duration || 0}
|
||||
step="1"
|
||||
.value=${getCurrentProgress(stateObj)}
|
||||
.withTooltip=${false}
|
||||
size="small"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.card.media_player.track_position"
|
||||
)}
|
||||
?disabled=${isBrowser ||
|
||||
!supportsFeature(stateObj, MediaPlayerEntityFeature.SEEK)}
|
||||
@change=${this._handleMediaSeekChanged}
|
||||
></ha-slider>`
|
||||
: html`
|
||||
<div class="progress">
|
||||
<div id="CurrentProgress"></div>
|
||||
<mwc-linear-progress wide></mwc-linear-progress>
|
||||
<ha-slider
|
||||
class="progress-slider"
|
||||
min="0"
|
||||
max=${stateObj.attributes.media_duration || 0}
|
||||
step="1"
|
||||
.value=${getCurrentProgress(stateObj)}
|
||||
.withTooltip=${false}
|
||||
size="small"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.card.media_player.track_position"
|
||||
)}
|
||||
?disabled=${isBrowser ||
|
||||
!supportsFeature(
|
||||
stateObj,
|
||||
MediaPlayerEntityFeature.SEEK
|
||||
)}
|
||||
@change=${this._handleMediaSeekChanged}
|
||||
></ha-slider>
|
||||
<div>${mediaDuration}</div>
|
||||
</div>
|
||||
`}
|
||||
`}
|
||||
</div>
|
||||
${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`
|
||||
<div class="choose-player ${isBrowser ? "browser" : ""}">
|
||||
@@ -294,26 +348,42 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
||||
stateObj &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET)
|
||||
? html`
|
||||
<ha-button-menu y="0" x="76">
|
||||
<ha-dropdown class="volume-menu" placement="top" .distance=${8}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.path=${mdiVolumeHigh}
|
||||
></ha-icon-button>
|
||||
<ha-slider
|
||||
labeled
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
.value=${stateObj.attributes.volume_level! * 100}
|
||||
@change=${this._handleVolumeChange}
|
||||
<div
|
||||
class="volume-slider-container"
|
||||
@touchstart=${this._volumeController.handleTouchStart}
|
||||
@touchmove=${this._volumeController.handleTouchMove}
|
||||
@touchend=${this._volumeController.handleTouchEnd}
|
||||
@touchcancel=${this._volumeController.handleTouchCancel}
|
||||
@wheel=${this._volumeController.handleWheel}
|
||||
>
|
||||
</ha-slider>
|
||||
</ha-button-menu>
|
||||
<ha-slider
|
||||
class="volume-slider"
|
||||
labeled
|
||||
min="0"
|
||||
max="100"
|
||||
.step=${this._volumeStep}
|
||||
.value=${volumeValue}
|
||||
@input=${this._volumeController.handleInput}
|
||||
@change=${this._volumeController.handleChange}
|
||||
>
|
||||
</ha-slider>
|
||||
</div>
|
||||
</ha-dropdown>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<ha-button-menu>
|
||||
<ha-dropdown
|
||||
class="player-menu"
|
||||
placement="top-end"
|
||||
.distance=${8}
|
||||
@wa-select=${this._handlePlayerSelect}
|
||||
>
|
||||
${
|
||||
this.narrow
|
||||
? html`
|
||||
@@ -342,26 +412,24 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
||||
</ha-button>
|
||||
`
|
||||
}
|
||||
<ha-list-item
|
||||
.player=${BROWSER_PLAYER}
|
||||
?selected=${isBrowser}
|
||||
@click=${this._selectPlayer}
|
||||
<ha-dropdown-item
|
||||
class=${isBrowser ? "selected" : ""}
|
||||
.value=${BROWSER_PLAYER}
|
||||
>
|
||||
${this.hass.localize("ui.components.media-browser.web-browser")}
|
||||
</ha-list-item>
|
||||
</ha-dropdown-item>
|
||||
${this._mediaPlayerEntities.map(
|
||||
(source) => html`
|
||||
<ha-list-item
|
||||
?selected=${source.entity_id === this.entityId}
|
||||
<ha-dropdown-item
|
||||
class=${source.entity_id === this.entityId ? "selected" : ""}
|
||||
.disabled=${source.state === UNAVAILABLE}
|
||||
.player=${source.entity_id}
|
||||
@click=${this._selectPlayer}
|
||||
.value=${source.entity_id}
|
||||
>
|
||||
${computeStateName(source)}
|
||||
</ha-list-item>
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
)}
|
||||
</ha-button-menu>
|
||||
</ha-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user