diff --git a/.prettierignore b/.prettierignore index 1e2e6c8a18ba..dde2819fadd1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -25,6 +25,9 @@ millicast/api/ theolive/api/ theolive_versioned_docs/*/api/ +# Third-party code +theoplayer/static/theoplayer/*/examples/peak-limiter/lookahead-limiter-*.mjs + # Submodules theoplayer/external/ theoplayer_versioned_docs/*/external/ diff --git a/spellcheck/dictionary.txt b/spellcheck/dictionary.txt index a7d63598f188..10dcef71e40b 100644 --- a/spellcheck/dictionary.txt +++ b/spellcheck/dictionary.txt @@ -1024,3 +1024,5 @@ Zettabyte HLG HLG-compatible UIs +bookmarklet +lookahead diff --git a/theoplayer/examples/_components/ClipApiWithPlaylistDemo.tsx b/theoplayer/examples/_components/ClipApiWithPlaylistDemo.tsx new file mode 100644 index 000000000000..ad22ca6ee20a --- /dev/null +++ b/theoplayer/examples/_components/ClipApiWithPlaylistDemo.tsx @@ -0,0 +1,140 @@ +import React, { type JSX, useRef, useState } from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import Example, { type ExampleController } from '@site/src/components/Example'; +import styles from '../shared.module.css'; + +interface PlaylistItem { + title: string; + poster: string; + clip: { startTime: number; endTime: number }; +} + +interface PlaylistState { + active: number; + items: PlaylistItem[]; +} + +function formatTime(seconds: number): string { + const minutes = Math.floor(seconds / 60); + return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`; +} + +export default function ClipApiWithPlaylistDemo(): JSX.Element { + const exampleRef = useRef(null); + const [state, setState] = useState({ active: 0, items: [] }); + const [form, setForm] = useState({ src: '', title: 'My clip', startTime: '0', endTime: '30' }); + + const startTime = Number(form.startTime); + const endTime = Number(form.endTime); + const canAdd = form.src.trim() !== '' && Number.isFinite(startTime) && Number.isFinite(endTime) && startTime >= 0 && endTime > startTime; + + function updateForm(key: keyof typeof form, value: string) { + setForm((previous) => ({ ...previous, [key]: value })); + } + + return ( + <> + { + if (message.type === 'playlist-state') { + setState({ + active: typeof message.active === 'number' ? message.active : 0, + items: Array.isArray(message.items) ? (message.items as PlaylistItem[]) : [], + }); + } + }} + /> +
+
+

Playlist

+ + +
+
    + {state.items.map((item, index) => ( +
  • + +
  • + ))} +
+
+
+

Add a clip

+
+ + + + +
+
+ +
+
+ + ); +} diff --git a/theoplayer/examples/_components/PeakLimiterDemo.tsx b/theoplayer/examples/_components/PeakLimiterDemo.tsx new file mode 100644 index 000000000000..d70a06d545ac --- /dev/null +++ b/theoplayer/examples/_components/PeakLimiterDemo.tsx @@ -0,0 +1,99 @@ +import React, { type JSX, useRef, useState } from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import Example, { type ExampleController } from '@site/src/components/Example'; +import styles from '../shared.module.css'; + +export default function PeakLimiterDemo(): JSX.Element { + const exampleRef = useRef(null); + const [boost, setBoost] = useState(18); + const [ceiling, setCeiling] = useState(-1); + const [enabled, setEnabled] = useState(true); + const [peak, setPeak] = useState(null); + const level = peak === null ? 0 : Math.min(100, Math.max(0, (peak + 48) / 54) * 100); + + return ( + <> + { + if (message.type === 'limiter-peak') setPeak(message.decibels as number | null); + }} + /> +
+ + + + + + + + + + + + + +
+ + + { + const decibels = Number(event.target.value); + setBoost(decibels); + exampleRef.current?.postMessage({ type: 'limiter-boost', decibels }); + }} + /> + +{boost} dB
+ + + { + const decibels = Number(event.target.value); + setCeiling(decibels); + exampleRef.current?.postMessage({ type: 'limiter-ceiling', decibels }); + }} + /> + {ceiling} dB
+
+ +
+
+
+ Output peak + {peak === null ? '-โˆž dB' : `${peak.toFixed(1)} dB`} +
+
+
0 ? 'var(--ifm-color-danger)' : 'var(--ifm-color-success)' }} + /> +
+
+
+ + ); +} diff --git a/theoplayer/examples/_components/VideoFitDemo.tsx b/theoplayer/examples/_components/VideoFitDemo.tsx new file mode 100644 index 000000000000..9618a065f837 --- /dev/null +++ b/theoplayer/examples/_components/VideoFitDemo.tsx @@ -0,0 +1,42 @@ +import React, { type CSSProperties, type JSX, useRef, useState } from 'react'; +import clsx from 'clsx'; +import Example, { type ExampleController } from '@site/src/components/Example'; +import styles from '../shared.module.css'; + +const fits = ['contain', 'cover', 'fill'] as const; + +interface VideoFitDemoProps { + src: string; + style?: CSSProperties; + exposeGlobals?: boolean; +} + +export default function VideoFitDemo({ src, style, exposeGlobals }: VideoFitDemoProps): JSX.Element { + const exampleRef = useRef(null); + const [fit, setFit] = useState<(typeof fits)[number]>('contain'); + return ( + <> + +
+
+
+ {fits.map((value) => ( + + ))} +
+
+
+ + ); +} diff --git a/theoplayer/examples/_components/WebAudioDemo.tsx b/theoplayer/examples/_components/WebAudioDemo.tsx new file mode 100644 index 000000000000..340ea7b061ef --- /dev/null +++ b/theoplayer/examples/_components/WebAudioDemo.tsx @@ -0,0 +1,79 @@ +import React, { type JSX, useRef, useState } from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import Example, { type ExampleController } from '@site/src/components/Example'; +import styles from '../shared.module.css'; + +const params = [ + { name: 'pan', label: 'Pan', min: -1, max: 1, step: 0.05, value: 0, format: (value: number) => value.toFixed(2) }, + { name: 'bass', label: 'Bass', min: -12, max: 12, step: 1, value: 0, format: (value: number) => `${value} dB` }, + { name: 'mid', label: 'Mid', min: -12, max: 12, step: 1, value: 0, format: (value: number) => `${value} dB` }, + { name: 'treble', label: 'Treble', min: -12, max: 12, step: 1, value: 0, format: (value: number) => `${value} dB` }, + { name: 'volume', label: 'Volume', min: 0, max: 1, step: 0.01, value: 1, format: (value: number) => `${Math.round(value * 100)}%` }, +] as const; + +type ParamName = (typeof params)[number]['name']; + +const defaults = Object.fromEntries(params.map(({ name, value }) => [name, value])) as Record; + +export default function WebAudioDemo(): JSX.Element { + const exampleRef = useRef(null); + const [values, setValues] = useState(defaults); + const [bypass, setBypass] = useState(false); + + function setParam(name: ParamName, value: number) { + setValues((prev) => ({ ...prev, [name]: value })); + exampleRef.current?.postMessage({ type: 'web-audio-param', name, value }); + } + + return ( + <> + +
+

Press play, then move the sliders. The audio context starts after you interact with the player.

+ + + {params.map(({ name, label, min, max, step, format }) => ( + + + + + + ))} + +
+ + + setParam(name, Number(event.target.value))} + /> + {format(values[name])}
+
+ + +
+
+ + ); +} diff --git a/theoplayer/examples/_components/YospaceDemo.tsx b/theoplayer/examples/_components/YospaceDemo.tsx new file mode 100644 index 000000000000..c5e482779058 --- /dev/null +++ b/theoplayer/examples/_components/YospaceDemo.tsx @@ -0,0 +1,83 @@ +import React, { type JSX, useRef, useState } from 'react'; +import clsx from 'clsx'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import Example, { type ExampleController } from '@site/src/components/Example'; +import styles from '../shared.module.css'; + +const streams = [ + { id: 'hls', label: 'HLS live' }, + { id: 'hlsPause', label: 'HLS live-pause' }, + { id: 'dash', label: 'DASH live-pause' }, +] as const; + +interface EventEntry { + time: string; + event: string; + details: string; +} + +interface AdBreak { + ads: number; + timeOffset: number; +} + +export default function YospaceDemo(): JSX.Element { + const exampleRef = useRef(null); + const [stream, setStream] = useState('hls'); + const [loading, setLoading] = useState(true); + const [adBreak, setAdBreak] = useState(null); + const [events, setEvents] = useState([]); + + return ( + <> + { + if (message.type === 'yospace-stream') { + setStream(message.stream as string); + setLoading(message.loading as boolean); + } else if (message.type === 'yospace-adbreak') { + setAdBreak(message.adBreak as AdBreak | null); + } else if (message.type === 'yospace-events') { + setEvents(message.events as EventEntry[]); + } + }} + /> +
+
+
+ {streams.map(({ id, label }) => ( + + ))} +
+
+

+ Current ad break: {adBreak ? `${adBreak.ads} ad(s), at ${adBreak.timeOffset} s` : 'none'} +

+
+ {events.length === 0 ? ( + No events reported yet. + ) : ( + events.map((entry, index) => ( +
+ {entry.time} + {entry.event} + {entry.details} +
+ )) + )} +
+
+ + ); +} diff --git a/theoplayer/examples/ads/04-yospace-ssai.mdx b/theoplayer/examples/ads/04-yospace-ssai.mdx new file mode 100644 index 000000000000..82f592b013b7 --- /dev/null +++ b/theoplayer/examples/ads/04-yospace-ssai.mdx @@ -0,0 +1,50 @@ +--- +sidebar_custom_props: { icon: '๐Ÿงต' } +description: Play Yospace server-side ad insertion (SSAI) streams with the Yospace connector. +--- + +# Yospace SSAI + +Play a Yospace stream with server-side ad insertion (SSAI) and follow its ad breaks in the event log. + +import CodeBlock from '@theme/CodeBlock'; +import demoSource from '!!raw-loader!../../static/theoplayer/v11/examples/yospace/demo.html'; +import YospaceDemo from '../_components/YospaceDemo'; + + + +With SSAI, the Yospace server stitches the ads into the stream itself, so the player plays one continuous stream. +The Yospace Ad Management SDK reads the ad information that comes with the stream, and the [Yospace connector](https://github.com/THEOplayer/web-connectors/tree/main/yospace) +reports the ad breaks and ads through the player's [`Ads`](pathname:///theoplayer/v11/api-reference/web/interfaces/Ads.html) API. +This example uses Yospace validation streams, which have a short ad break at regular intervals. + +Load the Yospace Ad Management SDK (3.10.0 or newer) and the connector, then create the connector and set up a Yospace session. +The connector creates the session and sets the stream it gets back from Yospace as the player source. + +```js +const yospace = new THEOplayerYospaceConnector.YospaceConnector(player); +await yospace.setupYospaceSession({ + sources: [ + { + src: 'https://csm-e-sdk-validation-eb.bln1.yospace.com/csm/extlive/yosdk02,hls-ts-pre.m3u8?yo.av=5', + ssai: { + integration: 'yospace', + streamType: 'live', // 'vod', 'live', 'livepause' or 'nonlinear' + }, + }, + ], +}); + +player.ads.addEventListener('adbreakbegin', (event) => { + console.log('Ad break with', event.adBreak.ads.length, 'ad(s)'); +}); +``` + +The Yospace Ad Management SDK isn't available on npm: get it from Yospace and host it yourself. + +
+ Code + + {demoSource} + +
diff --git a/theoplayer/examples/playback/08-clip-api-with-playlist.mdx b/theoplayer/examples/playback/08-clip-api-with-playlist.mdx new file mode 100644 index 000000000000..269793432fd2 --- /dev/null +++ b/theoplayer/examples/playback/08-clip-api-with-playlist.mdx @@ -0,0 +1,42 @@ +--- +sidebar_custom_props: { icon: 'โœ‚๏ธ' } +description: Play only a part of each item in a playlist with the Clip API. +--- + +# Clip API with playlist + +Play only a part of each video in a playlist with the Clip API, and continue with the next item when a clip ends. + +import CodeBlock from '@theme/CodeBlock'; +import ClipApiWithPlaylistDemo from '../_components/ClipApiWithPlaylistDemo'; +import demoSource from '!!raw-loader!../../static/theoplayer/v11/examples/clip-api-with-playlist/demo.html'; + + + +Set `player.clip.startTime` and `player.clip.endTime` (in seconds) to limit playback to a part of the stream. +The player then starts at the clip's start, limits seeking to the clip, and fires `ended` at the clip's end. + +Changing the source resets the clip to the whole stream. To clip each playlist item, apply its clip again on `durationchange`, after you set the new source: + +```js +player.addEventListener('durationchange', () => { + player.clip.startTime = playlist[active].clip.startTime; + player.clip.endTime = playlist[active].clip.endTime; +}); + +player.addEventListener('ended', () => { + active++; + player.autoplay = true; + player.source = playlist[active].source; +}); +``` + +See the [Clipping](../../how-to-guides/web/miscellaneous/clipping.mdx) guide and the [`Clip` API](pathname:///theoplayer/v11/api-reference/web/interfaces/Clip.html). +To build a playlist without clips, see the [Playlist and caching](07-playlist-and-caching.mdx) example. + +
+ Code + + {demoSource} + +
diff --git a/theoplayer/examples/playback/09-web-audio-api.mdx b/theoplayer/examples/playback/09-web-audio-api.mdx new file mode 100644 index 000000000000..248aca496b42 --- /dev/null +++ b/theoplayer/examples/playback/09-web-audio-api.mdx @@ -0,0 +1,43 @@ +--- +sidebar_custom_props: { icon: '๐ŸŽš๏ธ' } +description: Route the player's audio through the Web Audio API to pan, equalize and visualize it. +--- + +# Web Audio API + +Route the player's audio through the Web Audio API to pan it, change its tone with an equalizer, and draw its spectrum. + +import CodeBlock from '@theme/CodeBlock'; +import demoSource from '!!raw-loader!../../static/theoplayer/v11/examples/web-audio/demo.html'; +import WebAudioDemo from '../_components/WebAudioDemo'; + + + +`player.audio.createAudioSourceNode(context)` returns an audio node with the player's audio. Connect it to other nodes of the same [`AudioContext`](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext). +Once the player's audio goes through the audio graph, you only hear it if the graph ends at `context.destination`. + +```js +const context = new AudioContext(); +const source = player.audio.createAudioSourceNode(context); +const panner = new StereoPannerNode(context); +const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 250 }); +const volume = new GainNode(context); +const analyser = new AnalyserNode(context, { fftSize: 256 }); + +source.connect(panner).connect(bass).connect(volume).connect(analyser).connect(context.destination); + +// Change values smoothly to avoid audible clicks. +bass.gain.setTargetAtTime(6, context.currentTime, 0.02); +``` + +Browsers start an `AudioContext` in the `suspended` state until the viewer interacts with the page, and the player is silent while the context is suspended. +Call `context.resume()` when playback starts or after a click or key press, as this example does. + +See the [`Audio` API](pathname:///theoplayer/v11/api-reference/web/interfaces/Audio.html) and the [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API) documentation on MDN. + +
+ Code + + {demoSource} + +
diff --git a/theoplayer/examples/playback/10-peak-limiter.mdx b/theoplayer/examples/playback/10-peak-limiter.mdx new file mode 100644 index 000000000000..df38d5114222 --- /dev/null +++ b/theoplayer/examples/playback/10-peak-limiter.mdx @@ -0,0 +1,44 @@ +--- +sidebar_custom_props: { icon: '๐Ÿ”Š' } +description: Boost the player's audio without clipping by routing it through a lookahead peak limiter. +--- + +# Peak limiter + +Make quiet audio louder without clipping: boost the player's audio and keep its peaks under a ceiling with a lookahead limiter. + +import CodeBlock from '@theme/CodeBlock'; +import demoSource from '!!raw-loader!../../static/theoplayer/v11/examples/peak-limiter/demo.html'; +import PeakLimiterDemo from '../_components/PeakLimiterDemo'; + + + +Turn the limiter off to hear the boosted audio clip: in loud scenes, the output peak goes above 0 dB and the meter turns red. + +The Web Audio API has no built-in peak limiter. Its `DynamicsCompressorNode` adds makeup gain automatically, so it can't guarantee that peaks stay under a ceiling. +This example uses [`@synthlet/lookahead-limiter`](https://www.npmjs.com/package/@synthlet/lookahead-limiter), an open source (MIT) limiter that runs in an `AudioWorklet`. +It looks 2 ms ahead, so it lowers the gain before a peak arrives, and it also catches peaks between samples (true peaks). + +```js +import { registerLookaheadLimiterWorklet, LookaheadLimiter } from '@synthlet/lookahead-limiter'; + +const context = new AudioContext(); +await registerLookaheadLimiterWorklet(context); + +const source = player.audio.createAudioSourceNode(context); +const limiter = LookaheadLimiter(context, { + gain: 12, // boost in dB, applied before limiting + threshold: -1, // ceiling in dB +}); +source.connect(limiter).connect(context.destination); +``` + +The limiter delays the audio by about 2 ms. Browsers start an `AudioContext` in the `suspended` state until the viewer interacts with the page, so call `context.resume()` when playback starts. +See the [Web Audio API](./09-web-audio-api.mdx) example for more about routing the player's audio. + +
+ Code + + {demoSource} + +
diff --git a/theoplayer/examples/ui/09-video-fit.mdx b/theoplayer/examples/ui/09-video-fit.mdx new file mode 100644 index 000000000000..3ee737627f9c --- /dev/null +++ b/theoplayer/examples/ui/09-video-fit.mdx @@ -0,0 +1,59 @@ +--- +sidebar_custom_props: { icon: '๐Ÿ–ผ๏ธ' } +description: Change how the video fits inside the player with contain, cover or fill. +--- + +# Video fit + +import CodeBlock from '@theme/CodeBlock'; +import demoSource from '!!raw-loader!../../static/theoplayer/v11/examples/video-fit/demo.html'; +import defaultUiSource from '!!raw-loader!../../static/theoplayer/v11/examples/video-fit/default-ui.html'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import VideoFitDemo from '../_components/VideoFitDemo'; + +When the player and the video have different aspect ratios, you can choose how the video fits inside the player. +These examples use a 21:9 player for a 16:9 video. Switch between the values of the CSS +[`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) property to see the difference: + +- `contain` (default) shows the whole video, with black bars on the sides. +- `cover` fills the player, cutting off the top and bottom of the video. +- `fill` fills the player by stretching the video. + +## Open Video UI + + + +Set the `--theoplayer-video-object-fit` CSS custom property on the UI. `--theoplayer-aspect-ratio` sets the shape of the player itself. + +```css +theoplayer-default-ui { + --theoplayer-aspect-ratio: 21 / 9; + --theoplayer-video-object-fit: cover; +} +``` + +
+ Code + + {demoSource} + +
+ +## Default UI + + + +In the [Default UI](../../ui/web/default-ui/index.mdx), set `object-fit` on the `