Capabilities matrix
Capabilities come from the core PlayerCapabilities contract. Each control
below reads one capability (or, for Controls, several) and appears only when
that capability resolves available; while unknown or unavailable it
renders nothing.
| Component | Gating capability | Renders when |
|---|---|---|
Player.PlayButton |
— (provider presence) | always (not capability-gated) |
Player.MuteButton |
setVolume |
setVolume is available |
Player.VolumeSlider |
setVolume |
setVolume is available |
Player.SeekSlider |
seek |
seek is available |
Player.FullscreenButton |
fullscreen |
fullscreen is available |
Player.PipButton |
pictureInPicture |
pictureInPicture is available |
Player.AirPlayButton |
airPlay |
airPlay is available |
Player.RemotePlaybackButton |
remotePlayback |
remotePlayback is available |
Player.QualityMenu |
selectQuality |
selectQuality is available |
Player.PlaybackRateMenu |
setPlaybackRate |
setPlaybackRate is available |
Player.ChaptersMenu |
chapters |
chapters is available and non-empty |
Player.Time |
— (display) | always |
Player.Controls |
seek, setVolume, fullscreen, pictureInPicture |
container; children gate individually |
Player.QualityMenu's Auto row carries a second, narrower gate of its own —
selectQualityAuto — covered in the selectQuality section below.
airPlay reports whether there is somewhere to cast to, resolved in this
order (#71):
| Condition | airPlay |
|---|---|
No webkitShowPlaybackTargetPicker — anything but Safari and iOS |
unavailable / browser |
x-webkit-airplay="deny" or disableRemotePlayback |
unavailable / policy |
| No playback target announced yet, or the last one went away | unavailable / provider |
| WebKit announced an available playback target | available |
The third row is the new one: airPlay used to resolve available on the
picker API merely existing, so Player.AirPlayButton rendered inside Safari
even with no receiver on the network and opened an empty picker. It now
follows webkitplaybacktargetavailabilitychanged, which is Apple's own
guidance and what every native Apple player does.
Two consequences worth knowing:
- It is live. Plugging a receiver into the network makes the button appear without a reload, and unplugging it takes the button away again.
unavailable, notunknown, before the first announcement.unknownpromises a verdict is coming; on a machine that never sees a receiver the event never fires, and a consumer gating UI on it would wait forever.
@playdeck/provider-hls delegates to the native provider, so HLS inherits all of
this. The command itself is unchanged: showAirPlayPicker() still forwards to
WebKit whenever the API exists and policy permits, so a consumer driving it
directly is not blocked by the capability — the capability governs what
renders, not what is callable.
remotePlayback is the same shape, over the standards-based Remote
Playback API rather than WebKit's own — the media element's remote object,
which is Chrome's built-in route to Chromecast for a file or an HLS stream and
needs no Cast SDK, no sender script and no receiver page:
| Condition | remotePlayback |
|---|---|
No remote object on the element — no Remote Playback API |
unavailable / browser |
remote exists, but watchAvailability() reports no device |
unavailable / provider |
watchAvailability() reports a device |
available |
Also live — a receiver appearing or leaving the network flips the capability
with no reload — and also unavailable, not unknown, before the first
callback, for the same reason airPlay is: a machine that never sees a
receiver would otherwise leave a consumer waiting on a verdict that never
comes. @playdeck/provider-hls delegates to the native provider here too.
Once available, PlayerState.remotePlayback reflects the element's own
remote.state (connecting/connected/disconnected), and is null both
before that and once the capability goes back to unavailable. The Cast SDK
is a separate, unimplemented route — see docs/comparison/features.md's
Chromecast / Google Cast row.
selectTextTrack gates Player.CaptionsButton — see the Captions docs.
selectQuality gates Player.QualityMenu, which composes Player.SettingsMenu/
Player.MenuRadioGroup/Player.MenuRadioItem the same way Player.CaptionsMenu
does, listing PlayerState.qualities plus an "Auto" row — see below for which
providers populate that ladder. setPlaybackRate gates Player.PlaybackRateMenu
the same way, listing a rate ladder (default [0.5, 1, 1.5, 2], overridable via
its rates prop) instead of a provider-published one — see the Playback
rate guide. The Reference example's settings menu still composes its own
playback-rate group by hand over Player.SettingsMenu/Player.MenuRadioGroup,
for a consumer who wants it alongside quality under one trigger.
chapters gates Player.ChaptersMenu the same way, listing
PlayerState.chapters and marking the one containing currentTime — see the
Chapters guide. It says only whether the provider can report the
divisions in PlayerState.chapters at all; Player.ChaptersMenu additionally
renders nothing while that collection is empty. Chapter markers drawn on the
seek slider itself are out of scope for the shipped parts — a consumer
composes those over Player.SeekSlider, which already takes children.
customControls gates no shipped control.
commandsReady: when a command will actually land
A capability says whether a command is supported. PlayerState.commandsReady
says whether it is accepted yet. Commands issued before it are refused with
{ ok: false, reason: 'not-ready' } — they are not queued, and nothing is
replayed once the provider comes up.
Each provider declares readiness for itself, because the moment differs and
core cannot derive it: native declares after media.load() (which resets
playbackRate, so anything applied earlier is undone), hls.js at
MEDIA_ATTACHED, YouTube at onReady, Vimeo when its player is constructed,
and Wistia at api-ready — the moment <wistia-player> hands over the
PublicApi handle, which is the adapter's only command surface and does not
exist before it. activation is not a substitute — native with preload="none"
accepts commands while activation is still loading-provider.
whenReady() is the imperative form, and it sits on the player actions, on the
Player.Root ref handle, and on a bare PlayerController:
import * as Player from '@playdeck/react';
import { useEffect } from 'react';
// Declaratively: `commandsReady` means a command issued now is accepted and
// will not be undone by a load that has yet to run.
export const RateOnReady = () => {
const actions = Player.usePlayerActions();
const commandsReady = Player.usePlayerState((state) => state.commandsReady);
useEffect(() => {
if (commandsReady) void actions.setPlaybackRate(0.75);
}, [commandsReady, actions]);
return null;
};
// Imperatively: `whenReady()` resolves `true` at that same moment, and `false`
// if the player detaches, is swapped, or fails fatally. It never rejects and
// never hangs, and a call made before a provider attaches waits rather than
// answering `false`.
export const RateWhenReady = () => {
const actions = Player.usePlayerActions();
return (
<button
onClick={async () => {
if (await actions.whenReady()) void actions.setPlaybackRate(0.75);
}}
>
Slow down
</button>
);
};A call made before a provider is attached waits rather than answering false,
so it is safe to await from an effect that runs before mount finishes.
buffered: not every provider reports, and not every provider that does sees the same thing
PlayerState.buffered is what Player.SeekSlider paints behind the thumb. No
provider invents a range, but they see different amounts — and one sees nothing
at all:
| Provider | What the ranges are |
|---|---|
| native, hls | The <video> element's own TimeRanges — every range, exactly as buffered. |
| vimeo | The SDK's getBuffered() — every range, including gaps left by a seek. |
| youtube | One range, from where playback entered the buffer to that buffer's edge. |
| wistia | Nothing — the ranges are never published, and stay empty for the session. |
YouTube is the odd one out because its iframe API exposes no ranges at all —
only getVideoLoadedFraction(), which despite the name reports the end of the
range the playhead is in, over duration. The start of that range is never
exposed, so the adapter reports the earliest playhead position it has seen while
that same range held it: a start it can prove rather than one it assumes.
The practical difference from native, hls and vimeo: buffer loaded before you arrived is invisible. Join a video at 30s and the bar starts at 30s, however much sits behind it; seek backwards out of the range being tracked and the bar restarts from where you land, even though the region you left is still loaded. Everything YouTube reports is genuinely buffered — it just reports less than it holds.
Wistia is the one provider that reports nothing. Aurora fires no buffering
events and its PublicApi handle exposes no buffered ranges, so the adapter
publishes no ranges rather than a guess — and PlayerState.buffering stays
false throughout for the same reason.
On the other four, an empty array means "nothing is buffered ahead of the playhead". On Wistia it means only that Wistia does not say. There is no capability to check for the difference: gate on the provider if you need to tell the two apart, or treat an empty bar as "no information" and paint nothing.
selectQuality: two providers can, one is asked and ignored
| Provider | selectQuality |
Where the ladder comes from |
|---|---|---|
| hls.js | available with a manifest |
The manifest's levels, with height, width and bitrate |
| vimeo | available with a ladder |
getQualities() — one entry per rung, height only |
| native | unavailable, reason source |
The browser picks; nothing to enumerate |
| youtube | unavailable, reason provider |
Enumerable, but not selectable — see below |
| wistia | unavailable, reason provider |
No rung ladder to publish — see below |
auto is not a rung. Vimeo returns it as a member of getQualities() and
hls.js has an equivalent mode; both are reported as selectedQualityId: null
instead, so a menu has one representation of "let the player choose" whichever
provider is underneath.
selectQualityAuto: whether that representation is actually on offer
selectQuality says whether a provider can select a rung at all; it does not
say whether selectQuality(null) -- auto -- is one of the choices. The two
came apart in practice: hls.js always honours null once it has a ladder
(currentLevel = -1 never fails), but Vimeo's getQualities() does not
always carry an auto entry, and where it does not, selectQuality(null)
resolves { ok: false, reason: 'unsupported' } against a real ladder that
otherwise selects fine. PlayerCapabilities.selectQualityAuto is the signal
that tells the two cases apart, and Player.QualityMenu's Auto row is gated
on it rather than on selectQuality -- a menu built over the older, single
capability would render a radio item that silently does nothing when a
viewer chose it.
| Provider | selectQualityAuto |
|---|---|
| hls.js | Mirrors selectQuality exactly -- see above |
| vimeo | available only where the ladder has an auto entry |
| native | unavailable, reason source -- no ladder, no auto |
| youtube | unavailable, reason provider |
| wistia | unavailable, reason provider |
On Vimeo, the rung's height is Vimeo's own name for it, not a measurement:
the rung it labels 240p renders at 480×270. width and bitrate are null
because the SDK reports neither. Under auto the rung actually rendering is not
identified at all — the SDK marks auto itself as the active entry.
YouTube can enumerate but cannot select, so it offers neither.
getAvailableQualityLevels() returns an honest list once playback has started,
but setPlaybackQuality() is accepted and discarded: measured against the live
IFrame API, asking for each of the six levels the player itself offered left
getPlaybackQuality() on medium throughout, with no onPlaybackQualityChange
and no rebuffer. So did setting a level and then seeking, and so did
loadVideoById({ suggestedQuality }) — where the player announced its own
choice as medium after being asked for both tiny and hd720. Asking for
tiny failing in the same way as asking for hd720 is what rules out a
bandwidth or viewport ceiling: the argument is discarded, not clamped.
Publishing the list anyway would put rungs in the menu that cannot be chosen, so
the capability stays unavailable and qualities stays empty. The full
measurement is on #82.
Wistia has no ladder to publish. Aurora offers a coarse videoQuality()
setter and quality-min / quality-max attributes, but nothing that enumerates
the rungs actually available for a media, so the adapter does not wire quality
selection at all and qualities stays empty.
DASH is declined, not missing
The one adaptive format Playdeck plays is HLS. DASH is out of scope by decision
rather than by omission: an .mpd source is recognised and refused by name, with
a message saying Playdeck does not play DASH, so a consumer is told to change
their pipeline rather than left checking their URL. No release turns it on, and
no capability reports on it — a declined format is not a capability. The
reasoning is recorded in
.out-of-scope/dash.md
(#447).