Skip to content

Media player

One media_player.* entity, a speaker, a TV or a cast: whether it is playing, paused, buffering, idle, on, in standby or off; what is playing and how far in; its volume, whether it is muted, and its source; its artwork; and what it can do, from what it declares to Home Assistant.

import { useHaMediaPlayer } from "@dashhaus/core";
import { usePicture } from "@dashhaus/core/hooks";
import { mediaLine, mediaPlayerWord, mediaPress, pressWord } from "@dashhaus/core/entities/mediaPlayer/words";
/**
* What is playing, its artwork, and one button that does what the player can do now: pause it while it plays, play it
* while it doesn't, turn it on while it is off. What it can do comes from what it declares to Home Assistant.
*/
export function NowPlaying({ entityId }: { entityId: string }) {
const { entity, actions } = useHaMediaPlayer(entityId, { loadHistory: false });
const artwork = usePicture<HTMLSpanElement>(entity.picture);
const press = mediaPress(entity);
const run = { play: actions.onPlay, pause: actions.onPause, turnOn: actions.onTurnOn, turnOff: actions.onTurnOff };
return (
<figure>
<span ref={artwork.ref} style={{ display: "block", width: 96, height: 96 }}>
{artwork.src && <img src={artwork.src} alt="" width={96} height={96} />}
</span>
<figcaption>
{mediaLine(entity) ?? mediaPlayerWord(entity)}
{press && <button type="button" onClick={run[press]}>{pressWord(press)}</button>}
</figcaption>
</figure>
);
}

Integrations name the same state differently: an older one says stopped where Home Assistant says idle, another loading for buffering. They are mapped onto Home Assistant’s own, and any other state is said in its own words, Connecting, never guessed. A stream, such as a radio station, has no duration: nothing is drawn for how far in, rather than a guess.

Home Assistant reports a position and when it read it, not a ticking clock. usePlayhead, from @dashhaus/core/hooks, works out where the player is from those, once, and again only when the position, the state or the track changes; what you draw from it can then move on by itself with CSS, so nothing re-renders every second. clockPlaces gives a clock’s places, each a digit that turns every so many seconds, for a clock that keeps time the same way.

import type { CSSProperties } from "react";
import { useHaMediaPlayer } from "@dashhaus/core";
import { usePlayhead } from "@dashhaus/core/hooks";
import { playTimeSpoken, playedShare } from "@dashhaus/core/entities/mediaPlayer/position";
/**
* How far in, as a bar that fills by itself: one CSS animation as long as the track, started where it is now and held
* while it is paused. `key` starts it again when the position, the state or the track changes. In your stylesheet:
* `@keyframes fill { from { transform: scaleX(0) } to { transform: scaleX(1) } }`.
*/
export function Progress({ entityId }: { entityId: string }) {
const { entity } = useHaMediaPlayer(entityId, { loadHistory: false });
const head = usePlayhead(entity);
if (!head) return null;
const fill: CSSProperties = {
display: "block", height: 4, background: "currentColor", transformOrigin: "left",
transform: `scaleX(${playedShare(head)})`,
animation: `fill ${head.duration}s linear ${-head.at}s forwards`,
animationPlayState: head.running ? "running" : "paused",
};
return (
<span role="img" aria-label={`${playTimeSpoken(head.at)} of ${playTimeSpoken(head.duration)}`} style={{ display: "block", background: "#ddd" }}>
<span key={head.key} style={fill} />
</span>
);
}
usePlayhead(e: Pick<MediaPlayerEntity, "state" | "duration" | "position" | "positionAt">): PlayheadNow | undefined

Where a player is, worked out when its state, position or duration change, and only then: never on a timer. A card draws `at` and lets CSS run on from it while `running` (`@dashhaus/core/entities/mediaPlayer/position`), so the position ticks with no re-render. Nothing for a stream with no duration, or a player off, asleep or unavailable.

A player that declares it can seek (supportsSeek) moves to a position you give it in seconds: actions.onSeek, which answers true once Home Assistant has taken it and false when it didn’t. Each seek makes most players rebuffer, so seek once, when the finger lifts, not on every move. useSeek, from @dashhaus/core/hooks, does that: while a finger or a key is on the bar it holds a draft you draw, still; it seeks once on release; and the bar runs on from where it was let go until Home Assistant reports the new position, with no snap back. Its slider is what useSlider takes, from @dashhaus/core/primitives, whose onCommit is called once when a gesture ends.

import type { CSSProperties } from "react";
import { useHaMediaPlayer } from "@dashhaus/core";
import { usePlayhead, useSeek } from "@dashhaus/core/hooks";
import type { Seek } from "@dashhaus/core/hooks";
import { useSlider } from "@dashhaus/core/primitives";
import { playedShare } from "@dashhaus/core/entities/mediaPlayer/position";
/**
* How far in, as a bar you slide to seek. While a finger or a key is on it the fill shows the draft, held still; it
* seeks once, when it is let go, and runs on from there. Uses the same `fill` keyframes as the progress bar.
*/
function SeekBar({ seek }: { seek: Seek }) {
const slider = useSlider<HTMLDivElement>({ ...seek.slider, inset: 0 });
const { head, held } = seek;
const fill: CSSProperties = {
position: "absolute", inset: 0, background: "currentColor", transformOrigin: "left",
transform: `scaleX(${playedShare(head)})`,
animation: held ? "none" : `fill ${head.duration}s linear ${-head.at}s forwards`,
animationPlayState: head.running ? "running" : "paused",
};
return (
<div {...slider.rootProps} style={{ position: "relative", height: 32, background: "#ddd" }}>
<span key={head.key} style={fill} />
<div {...slider.hitProps} style={{ position: "absolute", inset: 0, touchAction: "pan-y" }} />
<input aria-label="Position" {...slider.inputProps} style={{ position: "absolute", opacity: 0, pointerEvents: "none" }} />
</div>
);
}
/** A player's position you can seek, where it declares that it seeks; nothing for a stream or a player that can't. */
export function SeekLine({ entityId }: { entityId: string }) {
const { entity, actions } = useHaMediaPlayer(entityId, { loadHistory: false });
const head = usePlayhead(entity);
const seek = useSeek(entity.supportsSeek ? head : undefined, actions.onSeek);
return seek ? <SeekBar seek={seek} /> : null;
}
useSeek(head: PlayheadNow | undefined, onSeek?: (seconds: number) => void | Promise<boolean>): Seek | undefined

A player's seek, for a skin's seek control (spec/cards.md → Using the media player card): the position slides as a draft while held, and seeks once, when it is let go (`onSeek`). After that the line runs on from where it was let go, under a new key, until Home Assistant reports the entity again (its key changes) or `onSeek` answers `false`: no snap back and no timeout. Nothing re-renders on a timer: once per move while held, once at release, once when Home Assistant reports. Nothing without a playhead (a stream, a player off, the dashboard's `quiet`) or without `onSeek`: the card then draws its readout. The card gates on what the player supports.

Some players step their volume rather than set it: a TV over HDMI-CEC, an AV receiver’s second zone, a Roku. They declare it (supportsVolumeStep), usually report no level, and take actions.onVolumeUp and actions.onVolumeDown, one step each. A step never unmutes the player or turns it on. Draw them as two buttons that each act once, never as a slider, which would claim a level the player never reported.

<HaMediaPlayer> is useHaMediaPlayer as a component that draws nothing of its own. It takes every option of the hook as a prop, and hands the hook’s result to its child function. Use it where hooks can’t go, such as one per item of a list.

entityIdstring
The Home Assistant entity id.
children(live: HaMediaPlayerResult) => ReactNode
Draws the hook's result. The component itself draws nothing.
useHaMediaPlayer(entityId: string, options?: HaMediaPlayerOptions): HaMediaPlayerResult

One live media player for any UI: the mapped entity with its artwork (its own `entity_picture`, resolved by the connection, while it is available and something is loaded), today's playing, and its service actions.

It reads the player, its area, its artwork and today’s playing.

name?string
area?string
Overrides the area Home Assistant's registries resolve.
lastSeen?string
history?MediaPlayerHistory
Host-owned history override; when omitted, today's history is read from Home Assistant.
loadHistory?boolean
Set false to skip the Home Assistant history read.
onActionError?(error: HaActionError) => void
entityMediaPlayerEntity
history?MediaPlayerHistory
actionsMediaPlayerHandlers

Each supports… flag comes from what the player declares to Home Assistant. What is playing, the volume and the source are known only while it is available. picture is its artwork, a URL your page can load, while something is loaded: draw it with usePicture, which loads it only once it is on screen.

entityIdstring
The Home Assistant entity id.
namestring
Its name: the `name` option, else Home Assistant's friendly name, else its entity id.
area?string
Where it is: "Kitchen".
stateMediaPlayerState
stateText?string
An `other` state in words, as the integration names it: `connecting` → "Connecting".
title?string
What is playing (HA `media_title`, else `media_channel`): "Sunday Morning".
artist?string
Who it is by, or what it is from (HA `media_artist`, else `media_album_artist`, else `media_series_title`).
album?string
The album (HA `media_album_name`).
app?string
The app playing it, where the player says (HA `app_name`): "Spotify".
duration?number
How long it is, in seconds (HA `media_duration`). Absent for a stream, which has no end.
position?number
How far in it was, in seconds (HA `media_position`), at `positionAt`.
positionAt?number
When `position` was read (HA `media_position_updated_at`), in ms since the epoch. Absent, it is as of when the card draws it (seeds), so a story's clock starts where its seed says.
volume?number
Its volume, 0–100 (HA `volume_level`, 0–1).
muted?boolean
Its sound is muted (HA `is_volume_muted`).
source?string
The source it plays from (HA `source`): "Spotify", "TV".
sources?string[]
The sources it can play from (HA `source_list`).
picture?string
Its artwork, a URL this page can load: the hook adds it from Home Assistant's `entity_picture` (`useHaPicture`); seeds give a local `data:` URI. Never read by the mapper.
supportsPlay?boolean
It plays (`MediaPlayerEntityFeature.PLAY`).
supportsPause?boolean
It pauses (`PAUSE`).
supportsPrevious?boolean
It skips back (`PREVIOUS_TRACK`).
supportsNext?boolean
It skips on (`NEXT_TRACK`).
supportsSeek?boolean
It moves to a position in what it plays (`SEEK`).
supportsVolume?boolean
Its volume can be set (`VOLUME_SET`).
supportsVolumeStep?boolean
Its volume steps up and down (`VOLUME_STEP`), often with no level reported (an IR or CEC TV, a receiver's zone).
supportsMute?boolean
It mutes (`VOLUME_MUTE`).
supportsSource?boolean
It picks a source (`SELECT_SOURCE`) and lists at least one.
supportsTurnOn?boolean
It turns on (`TURN_ON`).
supportsTurnOff?boolean
It turns off (`TURN_OFF`).
lastChanged?string
When it last changed state (HA `last_changed`), formatted.
changedAt?number
The same, in ms since the epoch.
lastSeen?string
While it is unavailable, when it was last reachable: the `lastSeen` option, else what Home Assistant reports.

Today, from Home Assistant’s recorder, kept up to date by every change since: the hours it was playing. Pass history to supply your own, or loadHistory={false} to skip the read.

samplesHourSample[]
1 for hours it was playing, 0 for hours it wasn't, "unavailable", null for hours ahead. Fractions for part of an hour.
currentHournumber
playingMinutesnumber
Minutes playing today, including the current hour.
onPlay() => void
Calls `media_player.media_play`. A player without `PLAY` reports `unsupported`.
onPause() => void
Calls `media_player.media_pause`. A player without `PAUSE` reports `unsupported`.
onPrevious() => void
Calls `media_player.media_previous_track`. A player without `PREVIOUS_TRACK` reports `unsupported`.
onNext() => void
Calls `media_player.media_next_track`. A player without `NEXT_TRACK` reports `unsupported`.
onTurnOn() => void
Calls `media_player.turn_on`. A player without `TURN_ON` reports `unsupported`.
onTurnOff() => void
Calls `media_player.turn_off`. A player without `TURN_OFF` reports `unsupported`.
onVolumeChange(percent: number) => void
Calls `media_player.volume_set` with the percent as a level from 0 to 1. A player without `VOLUME_SET` reports `unsupported`; a percent that isn't a number, `invalid_input`. It never unmutes or turns the player on.
onSeek(seconds: number) => Promise<boolean>
Calls `media_player.media_seek` with the position in seconds, kept within the track where its length is known, and answers `true` once Home Assistant has taken it, `false` when it didn't (reported through `onError`). A player without `SEEK` reports `unsupported`; seconds that aren't a number, `invalid_input`; both answer `false`.
onVolumeUp() => void
Calls `media_player.volume_up`: one step, as the player's own (Home Assistant registers it for `VOLUME_STEP` or `VOLUME_SET`). A player with neither reports `unsupported`. It never unmutes or turns the player on.
onVolumeDown() => void
Calls `media_player.volume_down`, as `onVolumeUp` does.
onMuteChange(muted: boolean) => void
Calls `media_player.volume_mute`. A player without `VOLUME_MUTE` reports `unsupported`.
onSourceChange(source: string) => void
Calls `media_player.select_source`. A player without `SELECT_SOURCE` reports `unsupported`; a source it doesn't list, `invalid_input`.

An action the player doesn’t declare, or any action while it is unavailable, is reported to onActionError as unsupported and never sent; a source it doesn’t list is reported as invalid_input.

Hooks can’t be called in a loop, so a list renders one headless component per entity. Lists skip each item’s history read with loadHistory={false}.

import { HaMediaPlayer, useHaSelector } from "@dashhaus/core";
import type { HaSnapshot } from "@dashhaus/core";
import { mediaLine, mediaPlayerWord } from "@dashhaus/core/entities/mediaPlayer/words";
const playerIds = (snapshot: HaSnapshot) =>
Object.keys(snapshot.entities).filter((id) => id.startsWith("media_player.")).sort().join(" ");
/** Every speaker and TV in the house, what it is doing, and what is playing on it. */
export function AllPlayers() {
const ids = useHaSelector(playerIds).split(" ").filter(Boolean);
return (
<ul>
{ids.map((id) => (
<li key={id}>
<HaMediaPlayer entityId={id} loadHistory={false}>
{({ entity }) => (
<>
{entity.name}: {mediaPlayerWord(entity)}
{mediaLine(entity) ? `, ${mediaLine(entity)}` : ""}
</>
)}
</HaMediaPlayer>
</li>
))}
</ul>
);
}

Orb’s HaMediaPlayerTile and Field’s HaMediaPlayerModule are useHaMediaPlayer above each skin’s card. One press pauses it while it plays, plays it while it doesn’t and turns it on while it is off; skipping, seeking (where the player can), the volume (or Down and Up for a player that only steps it), mute, the source and turning it off are in its details, never on the dashboard, where a stray tap would jump the track. Orb seeks along a groove in its sheet, Field along a Position fader in its panel. Its artwork is shown where there is room, never in a row. On a dashboard set quiet, where nothing changes by itself, they draw no line or clock for how far in. They take the hook’s options as props, plus their own:

import { HaMediaPlayerTile } from "@dashhaus/orb";
import { HaMediaPlayerModule } from "@dashhaus/field";
/** The hero of a kitchen panel: the artwork, what is playing and how far in. */
export function OrbMediaPlayer() {
return <HaMediaPlayerTile entityId="media_player.kitchen" footprint="featured" />;
}
/** A row for a phone's column: the first key, the name and what is playing, and the screen. */
export function FieldMediaPlayer() {
return <HaMediaPlayerModule entityId="media_player.living_room_tv" footprint="compact" />;
}
entityIdstring
showControl?boolean
Compact only. false removes the inline control, shows the value in its place, and makes the row open the details. Default true.
footprint?CardFootprint
Card footprint. The skin's stylesheet sets the heights; the three names are the shared vocabulary.
defaultOpen?boolean
defaultAction?undefined
quiet?boolean
Nothing on it runs by itself (the dashboard's `quiet`): no progress line and no clock, since a held one would lie and a running one changes on its own.

Every state of both is in Storybook: Orb and Field.