@v4/media

The only importer of the Stadion platform video SDK: configuration, cached server fetchers, normalised views, the player and the preview.

2026-11-07

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 from config.ts) and the isAPIError / APIError helpers.
  • @stadionhq-v3/platform-video-sdk-client-fetch — the fetch-based endpoint functions. This variant takes RequestInit, so Next's next: { 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. videoView lowercases entitlementTier and sets gated: true for any tier an anonymous visitor cannot watch (anything outside "", anonymous, free, public).
  • MediaRailView is a discriminated union on kind — 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. imageView resolves the API's focalPoint (normalised 0-1 or legacy pixel encodings) into a CSS objectPosition, 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. stillImageView builds 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 the stream.mux.com source), and muxPlayerUrl(playbackId, options) with MuxPlayerOptions (title, autoPlay, muted, isLive, accentColor) — the hosted player's embed address, with only the options that are set.
  • youtubePlayback(youtubeId) — a MediaPlaybackView for a YouTube id.
  • useStopFrameWhileHidden(frameRef, src), holdFrame(frame, src) and STOPPED_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). holdFrame is 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 as videoId (else read off the stream.mux.com HLS source).
  • youtube — the demo-space fallback. Renders a youtube-nocookie.com embed iframe, which keeps every YouTube-backed Contentful space working through the same component. The single source src is 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 touches document on 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.