@v4/media
The only importer of the Stadion platform video SDK: configuration, cached server fetchers, normalised views, the player and the preview.
Overview
@v4/media is the Stadion platform video SDK integration layer. Its defining
rule is that it is the only importer of the @stadionhq-v3/platform-video-sdk-*
packages in the whole monorepo — no route, component or app touches the SDK
directly. Everything downstream consumes the package's own normalised shapes.
That single boundary buys three things: the SDK's structurally-loose, everything-nullable responses are validated and normalised in one place; the provider details (Mux, the primary; YouTube; Brightcove) are hidden behind one player; and a change in the SDK is a change in one package rather than across a scatter of hook files.
How the package and the video routes over it fit together is explained in Media & video; this page is the package reference.
The SDK boundary
The package's source imports from exactly two SDK packages, each for one job:
@stadionhq-v3/platform-video-sdk-core—configureClient(called once fromconfig.ts) and theisAPIError/APIErrorhelpers.@stadionhq-v3/platform-video-sdk-client-fetch— the fetch-based endpoint functions. This variant takesRequestInit, so Next'snext: { tags, revalidate }cache options pass straight through (the react-query variant would lose them).
package.json also declares -react-query and -types, which nothing in src/
imports. Its other dependencies are @v4/image (the focal-point maths in
views.ts: nearestNamedRegion, objectPositionFromNormalised), hls.js
(pinned exactly, 1.6.5, loaded on demand by VideoPreview) and
@brightcove/web-sdk (dynamic-imported by the Brightcove branch of
MediaPlayer).
These are private packages on registry.npmjs.org, so pnpm install 404s
without auth. Provide an NPM_TOKEN (read-only granular token) locally, or
npm login as stadion-dev; CI uses the NPM_TOKEN GitHub secret. See the
Environment variables reference.
The three layers
config.ts configureMedia({ baseUrl, brightcoveAccountId, landingSlug, accentColor, token })
→ one configuration point; every server fetcher checks it ran
server.ts getVideoDetail / getPlayback / getVideoLanding / getCollection /
getPlaylist / getRail / getLatestVideos / getVideoPage /
getVideoFilterOptions
→ server-only fetchers with cache tags + React.cache dedupe
views.ts videoView / playbackView / playlistView / collectionView /
railView / landingView / videoDetailView / videoPageView /
seasonOptionsView / teamOptionsView / competitionOptionsView
(+ imageView / stillImageView / mediaImageUrl)
→ the SDK's nullable responses normalised into shapes the app trusts
The player and the preview are separate client entry points, kept apart from the server code because they touch the DOM.
Public entry points
| Import | Contents |
|---|---|
@v4/media |
configureMedia, mediaConfig, the nine fetchers, MEDIA_TAGS, every *View normaliser and its Media*View type (including MediaVideoPageView, MediaVideoFilterOptionsView, MediaFilterOptionView and MediaProvider). server-only |
@v4/media/views |
The normalisers and view types alone, without server-only, for a client module or a test |
@v4/media/player |
MediaPlayer (client), selectSource, youtubePlayback, muxPlaybackId, muxPlayerUrl / MuxPlayerOptions, the stop-while-hidden helpers (STOPPED_FRAME_SRC, useStopFrameWhileHidden, holdFrame) and the PlaybackProgress / PlaybackError types — see the media player |
@v4/media/preview |
VideoPreview (client): a muted, inline, looping <video> of a platform video, for a surface that plays a few seconds unattended (the video landing's hero, the video card's hover) |
@v4/media/preview-source |
The preview's pure helpers, with no directive: previewSource, muxHlsUrl, muxPlaybackIdFromPreviewUrl, muxPlaybackFromPreviewUrl, previewImageAtWidth, HLS_MIME, PREVIEW_LOAD_EVENT / PREVIEW_UNLOAD_EVENT / PREVIEW_LOAD_ATTRIBUTE |
./preview-source is its own subpath because ./preview is "use client", and
a function exported from a client module is a client REFERENCE on the server:
calling previewImageAtWidth() from a server component threw and sent the video
landing to its error page (13 Oct 2026). ./preview re-exports the helpers for
client callers; server code imports ./preview-source.
The video preview
VideoPreview plays, in order: the platform's HLS stream
(stream.mux.com/<id>.m3u8 for a Mux playback, any .m3u8 source otherwise) —
through hls.js, loaded on demand so the landing bundle does not carry it, and
natively only where Media Source is missing (desktop Chrome answers "maybe" to
canPlayType for HLS and then refuses the playlist); a plain video file source;
and the platform's animated previewUrl as an <img> when there is no stream or
it fails. It has no controls and no sound, is hidden from assistive technology,
streams at most 540 lines, and never starts itself: the composing component calls
play() / pause().
It also fetches NOTHING of its stream until the composer asks. The composer
dispatches PREVIEW_LOAD_EVENT (and PREVIEW_UNLOAD_EVENT) on the <video> and
writes its current wish on the slot as data-preview-load (PREVIEW_LOAD_ATTRIBUTE),
which a preview reads when it mounts — an event alone is lost by a preview that
was not yet listening. Plain DOM names, so ghost-video's drivePreviewLoading
needs no import from this package.
Configuration
The app configures the package once, from club.config, before any fetch runs.
apps/web/src/lib/media.ts builds the config from club.config.video and the
player accent colour, and configures the SDK as a side effect of being imported:
import { configureMedia } from "@v4/media";
import type { MediaConfig } from "@v4/media";
import clubConfig from "../../club.config";
import { PLAYER_ACCENT_COLOR } from "./player";
if (!clubConfig.video) {
throw new Error(
"club.config has no `video` section but a video route is composed — add video config or remove the domain",
);
}
export const mediaSettings: MediaConfig = {
baseUrl: accessValue(clubConfig.video.apiBaseUrl),
brightcoveAccountId:
clubConfig.video.brightcoveAccountId !== undefined
? accessValue(clubConfig.video.brightcoveAccountId)
: undefined,
landingSlug: clubConfig.video.landingSlug,
accentColor: PLAYER_ACCENT_COLOR,
};
configureMedia(mediaSettings);
accessValue unwraps a resolved server-secret env marker. accentColor is the
hosted Mux player's control colour (PLAYER_ACCENT_COLOR, from Euro League's
S.Color.Fill.Inverse.Impact, in apps/web/src/lib/player.ts); token is the
authenticated-endpoints hook, plumbed but unused while the vertical is
anonymous-first. mediaSettings is what the video route shims hand to each
factory.
configureMedia is idempotent per config value — reconfiguring with the same
baseUrl/token is a no-op, while a changed config re-points the SDK (test
environments swap URLs). Every fetcher calls an internal guard first; if the app
forgot to configure, mediaConfig() throws a message telling you exactly what to
call and where the app does it (apps/web/src/lib/media.ts).
Server fetchers and views
server.ts carries import "server-only" — the fetchers never reach a client
bundle. Each one rides the -client-fetch SDK package, normalises the response
through a view, and returns a shape the app can trust.
The fetchers
| Fetcher | Returns | Cache tag |
|---|---|---|
getVideoDetail(idOrSlug, locale?) |
MediaVideoDetailView | null |
media-video |
getPlayback(idOrSlug) |
MediaPlaybackResult (discriminated) |
none — always fresh |
getVideoLanding(idOrSlug, locale?) |
MediaLandingView | null |
media-landing |
getCollection(idOrSlug, params?) |
MediaCollectionView | null |
media-collection |
getPlaylist(idOrSlug, params?) |
MediaPlaylistView | null |
media-playlist |
getRail(railId, locale?) |
MediaRailView | null |
media-rail |
getLatestVideos(params?) |
MediaVideoView[] |
media-video |
getVideoPage(params?) |
MediaVideoPageView | null |
media-video |
getVideoFilterOptions() |
MediaVideoFilterOptionsView |
media-video |
getVideoPage is the video category page's list: one page of the latest videos
(/videos/latest), optionally in one category (category, the platform's own
filter on its category slug) and filtered by team, competition and season
entry ids, with the list's totalCount and totalPages. page is 0-based, as
the API pages. getVideoFilterOptions reads the choices for those filters — the
platform's /ContentData/ teams, competitions and seasons, each an { id, name }
list (an empty list where a read fails).
Two-tier caching
Each fetcher is wrapped in React.cache(), which dedupes identical calls within
a single render pass. Cross-request caching rides Next's own tags plus a
revalidation window — every tagged fetch passes
{ next: { tags: [tag], revalidate: 300 } } (300 seconds) through the SDK's
RequestInit.
// MEDIA_TAGS = { video: "media-video", landing: "media-landing",
// collection: "media-collection", playlist: "media-playlist", rail: "media-rail" }
MEDIA_TAGS names the fetch tags and the tag the landing's use cache assembly
carries. Nothing emits them yet: apps/web's revalidate route knows only the
content:* and ce:* vocabularies, so a media response expires by its 300-second
TTL alone until a platform webhook arrives. The tags are the names it will use
when it does.
getPlayback is the deliberate exception: playback URLs can be short-lived, so
it is fetched cache: "no-store" — always fresh, never tagged.
Errors become status, never throws
The SDK core throws APIError on any non-2xx response (a landing 404 escaped as
an uncaught error in testing). An internal attempt helper converts an
APIError back into a { status } envelope so callers keep one code path; a
non-API error (network failure, the config guard) still throws. In practice the
read fetchers return null (or []) on a non-200, and the page decides how to
degrade.
getPlayback goes further and returns a discriminated result, so a gated or
geo-blocked video degrades to a poster and a badge rather than an error:
export type MediaPlaybackResult =
{ ok: true; playback: MediaPlaybackView } | { ok: false; reason: "gated" | "unavailable" };
// 401/403 → "gated"; any other non-200 or empty sources → "unavailable"
Normalised views
The SDK's OpenAPI spec marks every field nullable and uses unions without shared
discriminants. views.ts normalises those responses once, at the package
boundary — nothing SDK-shaped leaks past this file. Each normaliser takes
unknown, validates field by field, and decides what is required for a view to
be renderable (drop the item otherwise) versus what degrades to undefined.
The exported views are MediaImageView, MediaVideoView, MediaPlaybackView,
MediaSourceView, MediaSponsorView, MediaPlaylistView, MediaCollectionView,
MediaCollectionFilterView, MediaRailView, MediaLandingView,
MediaVideoDetailView, MediaVideoPageView (a page of videos with
totalCount / totalPages), MediaVideoFilterOptionsView (teams,
competitions, seasons) and MediaFilterOptionView ({ id, name }), with
their normalisers (videoView, playbackView, playlistView,
collectionView, railView, landingView, videoDetailView,
videoPageView, seasonOptionsView, teamOptionsView,
competitionOptionsView, imageView). MediaProvider is the playback's
provider: brightcove | mux | jwplayer | youtube | other. They are also
importable from @v4/media/views, which has no server-only.
A few behaviours worth knowing when consuming them:
- Gating is derived, not passed through.
videoViewlowercasesentitlementTierand setsgated: truefor any tier an anonymous visitor cannot watch (anything outside"", anonymous, free, public). MediaRailViewis a discriminated union onkind—hero | playlist | popular | collection | unsupported— so an unknown rail type renders as{ kind: "unsupported", railType }rather than breaking the landing page.- Images carry a focal point.
imageViewresolves the API'sfocalPoint(normalised0-1or legacy pixel encodings) into a CSSobjectPosition, and prefers scoreplay-managed art.mediaImageUrl(image, width)re-requests a surface-appropriate crop from whichever CDN serves it — ScorePlay's dynamic-crop CDN, Contentful's Images API or Mux's image API (any other host is left unchanged);MEDIA_IMAGE_DEFAULT_WIDTH(1600) is the default crop. - A video with no picture shows its own still.
stillImageViewbuilds the Mux still frame from the preview's playback id, so the hero, its tiles, the cards and the detail poster never show an empty well.
The media player
MediaPlayer is the one player component, exported from the client entry point
@v4/media/player. It consumes a MediaPlaybackView — a provider name plus
source URLs from the platform playback endpoint — and never provider-native ids.
A provider swap is therefore a branch inside this component, not an API change.
import { MediaPlayer } from "@v4/media/player";
<MediaPlayer
playback={playback} // MediaPlaybackView from getPlayback
title="Match highlights" // accessible name for the region / iframe
brightcoveAccountId={accountId}
accentColor="#FA5500" // the hosted Mux player's controls
autoPlay
muted
/>;
Props
| Prop | Type | Notes |
|---|---|---|
playback |
MediaPlaybackView |
provider + source URLs |
title |
string |
accessible name for the player region / iframe title |
brightcoveAccountId |
string? |
from club.config via configureMedia |
autoPlay |
boolean? |
|
muted |
boolean? |
|
isLive |
boolean? |
|
onPlay |
(progress) => void |
it moved to playing: the first play and each resume (Mux, YouTube) |
onPause |
(progress) => void |
it paused, not the stop at the very end (Mux, YouTube) |
onEnded |
(progress) => void |
it finished; how a playlist moves on (Mux, YouTube) |
onError |
(error) => void |
it could not play (Mux, YouTube) |
onProgress |
(seconds, duration) => void |
about four times a second while it plays (Mux only) |
accentColor |
string? |
the hosted Mux player's control colour, a hex value; Mux only |
className |
string? |
progress is { currentTime, duration } in seconds (duration is undefined
until the player has said it); error is { code?, message? }, as the player
says it. Both types are exported from @v4/media/player.
accentColor travels in the Mux frame's address (accent-color), because a
frame cannot see the page's CSS variables. The app passes PLAYER_ACCENT_COLOR
(Euro League's S.Color.Fill.Inverse.Impact) through mediaSettings;
unset, Mux keeps its own default.
Other exports
@v4/media/player also exports:
selectSource(sources)— the HLS → DASH → mp4 → first preference.muxPlaybackId(playback)— the Mux playback id (videoId, else read off thestream.mux.comsource), andmuxPlayerUrl(playbackId, options)withMuxPlayerOptions(title,autoPlay,muted,isLive,accentColor) — the hosted player's embed address, with only the options that are set.youtubePlayback(youtubeId)— aMediaPlaybackViewfor a YouTube id.useStopFrameWhileHidden(frameRef, src),holdFrame(frame, src)andSTOPPED_FRAME_SRC(about:blank) — a frame player stops when its page is hidden. Next keeps a page the reader left mounted but hidden (React's<Activity>), and a hidden iframe keeps playing, so a layout effect's cleanup empties the frame in the commit that hides the page and the effect puts the player's address back when it is shown (found in the playlist player's QA).holdFrameis the effect's body, pure over the frame so it is testable without a DOM.
The muted preview the video hero and card play is a separate component,
VideoPreview, at @v4/media/preview — see the video preview.
Providers
MediaPlayer branches on playback.provider:
mux— Mux's hosted player (player.mux.com) in an iframe, by the public playback id the platform returns asvideoId(else read off thestream.mux.comHLS source).youtube— the demo-space fallback. Renders ayoutube-nocookie.comembed iframe, which keeps every YouTube-backed Contentful space working through the same component. The single sourcesrcis the YouTube video id;youtubePlayback(youtubeId)builds a matching view.brightcove(and any other provider carrying HLS/DASH sources) — the Brightcove web SDK. It is dynamic-imported inside an effect, never at module scope, because the SDK touchesdocumenton import; server rendering it would crash. Source preference is HLS → DASH → mp4 → first (selectSource).
Poster-then-play and graceful failure
The Brightcove branch renders its container as a role="region" element with the
title as its accessible name and a 16 / 9 aspect ratio, then attaches the
player in the effect once the SDK has loaded in the browser — so the layout box
(the poster surface) is present before playback begins, and the player is torn
down cleanly on unmount (detach → dispose → empty the container).
If the SDK import or attach fails, the component falls back to a native
<video> element pointed at the selected source URL — the sources are plain
URLs, so the browser can still try (Safari plays HLS natively). Combined with
getPlayback's discriminated result, that gives a full degradation ladder: a
gated video shows a poster and badge, an SDK failure falls back to native video,
and only a genuinely sourceless playback renders nothing.
Playback reports
onPlay, onPause, onEnded and onError are what the page's video events are
sent from (the tracking plan's video events, see @v4/analytics). Each provider's frame speaks its own protocol over
postMessage, and the player turns both into the same four calls
(player/playback.ts). Only messages from the frame's own window and origin
count.
| Provider | How it reports |
|---|---|
| Mux | Player.js (player/player-js.ts): once the frame says ready, the player subscribes to play, pause, ended, error and, for the time in each report, timeupdate — each only when the page asked for it. A first play that came before the subscription is read off the clock: the time moving on in a video that has not played. |
| YouTube | The IFrame API without YouTube's script (player/youtube-api.ts). A page that asks for a report gets enablejsapi=1 and its own origin on the embed's address; the player says it is listening until YouTube answers, then adds listeners for onStateChange and onError. A page that asks for none keeps the address it always had. |
| Brightcove | Nothing yet: whether it is still a launch provider is open (decision D4). |
One report per real change: a buffering stall that recovers is not a second
play, and the pause a video element fires just before ended is not reported
as a pause. Every end is reported. The article's own players (the video header
and the Video Item embed, in @v4/content-app) report the same four through
useProviderEvents; that package keeps its own copy of these rules because
@v4/media belongs to the video domain and @v4/content-app does not depend on it.