feat(site): API reference pages for media elements (#1342)

This commit is contained in:
Darius Cepulis
2026-06-19 09:44:54 -07:00
committed by GitHub
parent 1512729365
commit d799be1063
95 changed files with 3219 additions and 210 deletions
@@ -0,0 +1,30 @@
---
import MarkdownCode from '@/components/typography/MarkdownCode.astro';
interface Props {
value: string;
}
const { value } = Astro.props;
---
<span class="code-list__item"><MarkdownCode>{value}</MarkdownCode></span>
<style>
/* The separator is a pseudo-element, so it stays glued to its chip with no
text node between them — the formatter can't inject a space before the
comma, and there is no break opportunity there. The trailing space inside
the separator is the only break opportunity, so a wrap always lands after
the comma, never before it.
Keeping the chip on a single line in this dedicated component is what makes
it whitespace-clean: the formatter splits the same markup across lines when
it sits inside a `.map()` callback, which is what introduced the stray
spaces in the first place. */
.code-list__item:not(:last-child)::after {
content: ", ";
}
.code-list__item:last-child::after {
content: ".";
}
</style>
@@ -0,0 +1,69 @@
---
/**
* Renders the host properties table for media element API reference.
*
* Host properties are getter/setter pairs exposed by the media element's host
* class. They differ from standard HTML attributes — they're accessed via JS
* properties on the element instance.
*/
import MarkdownCode from '@/components/typography/MarkdownCode.astro';
import Table from '@/components/typography/Table.astro';
import Tbody from '@/components/typography/Tbody.astro';
import Td from '@/components/typography/Td.astro';
import Th from '@/components/typography/Th.astro';
import Thead from '@/components/typography/Thead.astro';
import Tr from '@/components/typography/Tr.astro';
import type { HostPropertyDef } from '@/types/media-reference';
import DetailRow from './DetailRow.astro';
interface Props {
hostProperties: Record<string, HostPropertyDef>;
componentName: string;
}
const { hostProperties, componentName } = Astro.props;
const entries = Object.entries(hostProperties).sort(([a], [b]) => a.localeCompare(b));
---
<Table maxWidth={false} outerClass="my-6">
<Thead>
<Tr>
<Th>Property</Th>
<Th>Type</Th>
<Th>Default</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{
entries.map(([name, def]) => {
const id = `${componentName}-${name}`;
const readonlyNote = def.readonly ? "Read-only." : undefined;
const description =
[readonlyNote, def.description].filter(Boolean).join(" ") ||
undefined;
return (
<DetailRow
id={id}
name={name}
type={def.type}
description={description}
colspan={4}
>
<Td class="align-top">
<MarkdownCode>{name}</MarkdownCode>
</Td>
<Td class="align-top">
<MarkdownCode>{def.type}</MarkdownCode>
</Td>
<Td class="align-top">
<MarkdownCode>{def.default ?? "—"}</MarkdownCode>
</Td>
</DetailRow>
);
})
}
</Tbody>
</Table>
@@ -0,0 +1,263 @@
---
import { getEntry } from 'astro:content';
import { kebabCase } from 'es-toolkit/string';
import ContentWidth from '@/components/frames/ContentWidth.astro';
import A from '@/components/typography/A.astro';
import H2 from '@/components/typography/H2Markdown.astro';
import H3 from '@/components/typography/H3Markdown.astro';
import MarkdownCode from '@/components/typography/MarkdownCode.astro';
import P from '@/components/typography/P.astro';
import Table from '@/components/typography/Table.astro';
import Tbody from '@/components/typography/Tbody.astro';
import Td from '@/components/typography/Td.astro';
import Th from '@/components/typography/Th.astro';
import Thead from '@/components/typography/Thead.astro';
import Tr from '@/components/typography/Tr.astro';
import type { MediaReference } from '@/types/media-reference';
import { createMediaReferenceModel } from '@/utils/mediaReferenceModel';
import ApiCSSVarsTable from './ApiCSSVarsTable.astro';
import CodeChip from './CodeChip.astro';
import MediaHostPropsTable from './MediaHostPropsTable.astro';
interface Props {
media: string;
}
const { media } = Astro.props;
const framework = Astro.params.framework === 'react' ? 'react' : 'html';
const entry = await getEntry('mediaReference', kebabCase(media));
const ref: MediaReference | null = entry?.data ?? null;
if (!ref) return;
const model = createMediaReferenceModel(media, ref);
if (!model) return;
const hostPropsSection = model.sections.find((s: { key: string }) => s.key === 'hostProperties');
const nativeAttrsSection = model.sections.find((s: { key: string }) => s.key === 'nativeAttributes');
const eventsSection = model.sections.find((s: { key: string }) => s.key === 'events');
const methodsSection = model.sections.find((s: { key: string }) => s.key === 'methods');
const cssVarsSection = model.sections.find((s: { key: string }) => s.key === 'cssCustomProperties');
const mediaTag = `<${ref.mediaType}>`;
const mdnElementUrl = `https://developer.mozilla.org/en-US/docs/Web/HTML/Element/${ref.mediaType}`;
const mdnEventsUrl = 'https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement#events';
const mdnMethodsUrl = 'https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement';
// React prop spellings for the native attributes that differ from their HTML
// attribute names.
const REACT_PROP_NAMES: Record<string, string> = {
autoplay: 'autoPlay',
playsinline: 'playsInline',
crossorigin: 'crossOrigin',
};
const sortedNativeAttributes = [...(ref.nativeAttributes ?? [])].sort();
const attributeNames =
framework === 'react' ? sortedNativeAttributes.map((attr) => REACT_PROP_NAMES[attr] ?? attr) : sortedNativeAttributes;
const sortedNativeEvents = [...(ref.events.native ?? [])].sort();
const sortedMethods = [...(ref.methods ?? [])].sort();
// React exposes synthetic event props for the standard media events only.
// Track-list and Picture-in-Picture events have no prop and must be handled
// with a ref + addEventListener. Source: @types/react DOMAttributes.
const REACT_MEDIA_EVENTS = new Set([
'abort',
'canplay',
'canplaythrough',
'durationchange',
'emptied',
'ended',
'error',
'loadeddata',
'loadedmetadata',
'loadstart',
'pause',
'play',
'playing',
'progress',
'ratechange',
'resize',
'seeked',
'seeking',
'stalled',
'suspend',
'timeupdate',
'volumechange',
'waiting',
]);
const reactMappedEvents = sortedNativeEvents.filter((event) => REACT_MEDIA_EVENTS.has(event));
const reactRefOnlyEvents = sortedNativeEvents.filter((event) => !REACT_MEDIA_EVENTS.has(event));
const elementSpecificEvents = ref.events.elementSpecific;
---
<ContentWidth>
<H2 id={model.heading.id}>{model.heading.text}</H2>
{
hostPropsSection && (
<>
<H3 id={hostPropsSection.id}>{hostPropsSection.title}</H3>
<MediaHostPropsTable
hostProperties={ref.hostProperties}
componentName={media}
/>
</>
)
}
{
nativeAttrsSection && framework === "html" && (
<>
<H3 id={nativeAttrsSection.id}>{nativeAttrsSection.title}</H3>
<P>
Forwards these media attributes to the internal{" "}
<MarkdownCode>{mediaTag}</MarkdownCode> element. The standard ones
behave as described in the{" "}
<A href={mdnElementUrl}>MDN media attributes reference</A>:{" "}
<span class="code-list">
{attributeNames.map((attr) => (
<CodeChip value={attr} />
))}
</span>
</P>
</>
)
}
{
nativeAttrsSection && framework === "react" && (
<>
<H3 id={nativeAttrsSection.id}>{nativeAttrsSection.title}</H3>
<P>
Renders a native <MarkdownCode>{mediaTag}</MarkdownCode> element and
accepts these media attributes as React props. The standard ones
behave as described in the{" "}
<A href={mdnElementUrl}>MDN media attributes reference</A>:{" "}
<span class="code-list">
{attributeNames.map((attr) => (
<CodeChip value={attr} />
))}
</span>
</P>
</>
)
}
{
eventsSection && (
<>
<H3 id={eventsSection.id}>{eventsSection.title}</H3>
{framework === "html" ? (
<P>
Re-dispatches these media events from the internal{" "}
<MarkdownCode>{mediaTag}</MarkdownCode> element, so you can listen
for them directly. See <A href={mdnEventsUrl}>media events</A>:{" "}
<span class="code-list">
{sortedNativeEvents.map((event) => (
<CodeChip value={event} />
))}
</span>
</P>
) : (
<>
<P>
Handle these media events with React event props (e.g.{" "}
<MarkdownCode>onPlay</MarkdownCode>,{" "}
<MarkdownCode>onTimeUpdate</MarkdownCode>). See{" "}
<A href={mdnEventsUrl}>media events</A>:{" "}
<span class="code-list">
{reactMappedEvents.map((event) => (
<CodeChip value={event} />
))}
</span>
</P>
{reactRefOnlyEvents.length > 0 && (
<P>
These media events have no React prop — attach a listener
through a <MarkdownCode>ref</MarkdownCode> with{" "}
<MarkdownCode>addEventListener</MarkdownCode>:{" "}
<span class="code-list">
{reactRefOnlyEvents.map((event) => (
<CodeChip value={event} />
))}
</span>
</P>
)}
</>
)}
{elementSpecificEvents.length === 0 ? (
<P>
This element dispatches no events beyond the media events above.
</P>
) : (
<>
<P>Beyond the media events above, this element emits:</P>
<Table maxWidth={false} outerClass="my-6">
<Thead>
<Tr>
<Th>Event</Th>
<Th>Description</Th>
</Tr>
</Thead>
<Tbody>
{elementSpecificEvents.map((event) => (
<Tr>
<Td class="align-top">
<MarkdownCode>{event.name}</MarkdownCode>
</Td>
<Td class="align-top">{event.description ?? ""}</Td>
</Tr>
))}
</Tbody>
</Table>
{framework === "react" && (
<P>
These events have no built-in React prop. Attach a listener to
the element through a <MarkdownCode>ref</MarkdownCode> with{" "}
<MarkdownCode>addEventListener</MarkdownCode>.
</P>
)}
</>
)}
</>
)
}
{
methodsSection && (
<>
<H3 id={methodsSection.id}>{methodsSection.title}</H3>
<P>
Supports these methods from the native media API. See{" "}
<A href={mdnMethodsUrl}>HTMLMediaElement</A> for the core methods:{" "}
<span class="code-list">
{sortedMethods.map((method) => (
<CodeChip value={method} />
))}
</span>
</P>
{framework === "react" && (
<P>
In React, call these through a <MarkdownCode>ref</MarkdownCode> to
the element.
</P>
)}
</>
)
}
{
cssVarsSection && (
<>
<H3 id={cssVarsSection.id}>{cssVarsSection.title}</H3>
<ApiCSSVarsTable
cssCustomProperties={ref.cssCustomProperties}
componentName={media}
/>
</>
)
}
</ContentWidth>
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -0,0 +1,5 @@
.container {
display: grid;
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,5 @@
<div class="container">
<background-video
src="https://stream.mux.com/601n4w1fq88NJiVpzvrQQeQfNnnjjfKMIN7dCGAEarTs/highest.mp4"
></background-video>
</div>
@@ -0,0 +1 @@
import '@videojs/html/media/background-video';
@@ -0,0 +1,5 @@
.container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,9 @@
import { BackgroundVideo } from '@videojs/react/media/background-video';
export default function BasicUsage() {
return (
<div className="container">
<BackgroundVideo src="https://stream.mux.com/601n4w1fq88NJiVpzvrQQeQfNnnjjfKMIN7dCGAEarTs/highest.mp4" />
</div>
);
}
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -0,0 +1,6 @@
.media-container {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,9 @@
<media-container class="media-container">
<dash-video
src="https://dash.akamaized.net/akamai/streamroot/050714/Spring_4Ktest.mpd"
autoplay
muted
playsinline
loop
></dash-video>
</media-container>
@@ -0,0 +1,2 @@
import '@videojs/html/media/container';
import '@videojs/html/media/dash-video';
@@ -0,0 +1,4 @@
.dash-video {
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,14 @@
import { DashVideo } from '@videojs/react/media/dash-video';
export default function BasicUsage() {
return (
<DashVideo
className="dash-video"
src="https://dash.akamaized.net/akamai/streamroot/050714/Spring_4Ktest.mpd"
autoPlay
muted
playsInline
loop
/>
);
}
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -0,0 +1,6 @@
.media-container {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,9 @@
<media-container class="media-container">
<hls-video
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
autoplay
muted
playsinline
loop
></hls-video>
</media-container>
@@ -0,0 +1,2 @@
import '@videojs/html/media/container';
import '@videojs/html/media/hls-video';
@@ -0,0 +1,4 @@
.hls-video {
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,14 @@
import { HlsVideo } from '@videojs/react/media/hls-video';
export default function BasicUsage() {
return (
<HlsVideo
className="hls-video"
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
autoPlay
muted
playsInline
loop
/>
);
}
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -0,0 +1,4 @@
.mux-audio {
width: 100%;
height: 54px;
}
@@ -0,0 +1,6 @@
<mux-audio
class="mux-audio"
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
crossorigin="anonymous"
controls
></mux-audio>
@@ -0,0 +1 @@
import '@videojs/html/media/mux-audio';
@@ -0,0 +1,4 @@
.mux-audio {
width: 100%;
height: 54px;
}
@@ -0,0 +1,12 @@
import { MuxAudio } from '@videojs/react/media/mux-audio';
export default function BasicUsage() {
return (
<MuxAudio
className="mux-audio"
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
crossOrigin="anonymous"
controls
/>
);
}
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -0,0 +1,6 @@
.media-container {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,10 @@
<media-container class="media-container">
<mux-video
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
autoplay
muted
playsinline
loop
crossorigin="anonymous"
></mux-video>
</media-container>
@@ -0,0 +1,2 @@
import '@videojs/html/media/container';
import '@videojs/html/media/mux-video';
@@ -0,0 +1,4 @@
.mux-video {
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,15 @@
import { MuxVideo } from '@videojs/react/media/mux-video';
export default function BasicUsage() {
return (
<MuxVideo
className="mux-video"
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
autoPlay
muted
playsInline
loop
crossOrigin="anonymous"
/>
);
}
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -0,0 +1,6 @@
.media-container {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,9 @@
<media-container class="media-container">
<native-hls-video
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
autoplay
muted
playsinline
loop
></native-hls-video>
</media-container>
@@ -0,0 +1,2 @@
import '@videojs/html/media/container';
import '@videojs/html/media/native-hls-video';
@@ -0,0 +1,4 @@
.native-hls-video {
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,14 @@
import { NativeHlsVideo } from '@videojs/react/media/native-hls-video';
export default function BasicUsage() {
return (
<NativeHlsVideo
className="native-hls-video"
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
autoPlay
muted
playsInline
loop
/>
);
}
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -0,0 +1,4 @@
.simple-hls-audio-only {
width: 100%;
height: 54px;
}
@@ -0,0 +1,5 @@
<simple-hls-audio-only
class="simple-hls-audio-only"
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
controls
></simple-hls-audio-only>
@@ -0,0 +1 @@
import '@videojs/html/media/simple-hls-audio-only';
@@ -0,0 +1,4 @@
.simple-hls-audio-only {
width: 100%;
height: 54px;
}
@@ -0,0 +1,11 @@
import { SimpleHlsAudioOnly } from '@videojs/react/media/simple-hls-audio-only';
export default function BasicUsage() {
return (
<SimpleHlsAudioOnly
className="simple-hls-audio-only"
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
controls
/>
);
}
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -0,0 +1,6 @@
.media-container {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,9 @@
<media-container class="media-container">
<simple-hls-video
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
autoplay
muted
playsinline
loop
></simple-hls-video>
</media-container>
@@ -0,0 +1,2 @@
import '@videojs/html/media/container';
import '@videojs/html/media/simple-hls-video';
@@ -0,0 +1,4 @@
.simple-hls-video {
width: 100%;
aspect-ratio: 16 / 9;
}
@@ -0,0 +1,14 @@
import { SimpleHlsVideo } from '@videojs/react/media/simple-hls-video';
export default function BasicUsage() {
return (
<SimpleHlsVideo
className="simple-hls-video"
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
autoPlay
muted
playsInline
loop
/>
);
}