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.
Quick example
Section titled “Quick example”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.
How far in, without a timer
Section titled “How far in, without a timer”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 | undefinedWhere 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.
Seeking
Section titled “Seeking”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 | undefinedA 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.
Volume steps
Section titled “Volume steps”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.
Component
Section titled “Component”<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): HaMediaPlayerResultOne 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.
Options
Section titled “Options”name?stringarea?string- Overrides the area Home Assistant's registries resolve.
lastSeen?stringhistory?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
Returns
Section titled “Returns”entityMediaPlayerEntityhistory?MediaPlayerHistoryactionsMediaPlayerHandlers
entity: MediaPlayerEntity
Section titled “entity: MediaPlayerEntity”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".
stateMediaPlayerStatestateText?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.
history: MediaPlayerHistory
Section titled “history: MediaPlayerHistory”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.
currentHournumberplayingMinutesnumber- Minutes playing today, including the current hour.
actions: MediaPlayerHandlers
Section titled “actions: MediaPlayerHandlers”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.
Every player
Section titled “Every player”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> );}In a skin
Section titled “In a skin”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" />;}entityIdstringshowControl?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?booleandefaultAction?undefinedquiet?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.
entityIdstringfootprint?FieldFootprintdefaultOpen?boolean- Open the detail panel on first render (stories).
quiet?boolean- Nothing on it runs by itself (the dashboard's `quiet`): no progress rule and no running clock, since a held one would lie and a running one changes on its own; the panel's screen says the state's figure instead.