Media and video architecture
How video is built: the platform video SDK seam, the video routes, focal-point images and the EuroLeague TV livestream seam.
The media package
@v4/media is the platform video integration layer — and the only package
in the monorepo that imports the Stadion @stadionhq-v3/platform-video-sdk-*
packages. Everything else consumes normalised views and a provider-blind player,
so a provider change is a branch inside this package, never an API change felt
by its consumers. The SDK is on a private npm scope, so CI needs NPM_TOKEN —
here a token that reads both @stadionhq-v3 and @dicetechnology (the
ELTV Vesper SDK), see SECRETS.md.
The package has five exports:
| Subpath | Contents |
|---|---|
@v4/media |
configureMedia, the server fetchers, MEDIA_TAGS, and the normalised views |
@v4/media/views |
The view normalisers and their Media*View types |
@v4/media/player |
MediaPlayer — the one client player component (Brightcove, YouTube, Mux) |
@v4/media/preview |
VideoPreview — a muted inline HLS preview for the video hero's autoplay and a card's hover: native HLS in Safari, hls.js loaded on demand elsewhere, the platform's animated previewUrl as the fallback |
@v4/media/preview-source |
The preview's pure helpers, in a module with no directive: a function exported from a "use client" module is a client reference on the server, and calling one there throws |
One configuration point
The SDK core already carries configureClient for exactly this, so the app
configures media once rather than re-reading a base URL in a dozen hook files
and hand-building Bearer headers per call site:
import { configureMedia } from "@v4/media";
configureMedia({
baseUrl: "https://<club>.stadion.io/video-public",
brightcoveAccountId, // for the player
landingSlug, // the /video route's default landing
});
The web app calls this in apps/web/src/lib/media.ts, built from
club.config.ts. 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 server fetcher guards on it and throws a
useful message if the app forgot to configure.
Server fetchers ride the fetch SDK
The server half (import "server-only") uses the -client-fetch SDK variant
only, because it takes RequestInit — so Next's next: { tags, revalidate }
cache options pass straight through. The fetchers use two-tier caching:
React.cache() dedupes within a render pass, while tags + a 300-second
revalidate floor handle cross-request invalidation:
export const MEDIA_TAGS = {
video: "media-video",
landing: "media-landing",
collection: "media-collection",
playlist: "media-playlist",
rail: "media-rail",
} as const;
The fetchers — getVideoDetail, getVideoLanding, getCollection,
getPlaylist, getRail, getLatestVideos, getVideoPage (the category page's
filtered, paged list), getVideoFilterOptions (its season, team and competition
filters, from the platform's /ContentData/*) and getPlayback — each convert the
SDK's thrown APIError back into a status envelope so callers keep one code
path, and return null / [] on non-200 so a missing entry degrades rather than
throws. Two behaviours are deliberate:
getPlaybackiscache: "no-store"— playback URLs can be short-lived, so they are always fetched fresh, never tagged.- Playback is discriminated. A gated or geo-blocked video (401/403) comes
back as
{ ok: false, reason: "gated" }, so the page degrades to poster + badge instead of erroring.
Normalised views
The view layer maps raw SDK responses onto stable Media*View shapes
(MediaVideoView, MediaPlaybackView, MediaLandingView, and so on) — plain
data the ghost-video components and the route factories consume. The views also
own the image concerns: mediaImageUrl re-requests an image at a
surface-appropriate width through whichever CDN serves it (Scoreplay's
dynamic-crop CDN, Contentful's Images API or Mux's image host), cropping to the
slot's aspect (MediaCropOptions.aspect) around the focal region, and each view
carries an objectPosition derived from the API's focal point with the shared
@v4/image maths (see Images).
The video domain
Not the ELTV livestream. EuroLeague TV live streams are a separate seam, this site's own —
@v4/eltvover Deltatre's Vesper SDK — and use neitherMediaPlayer, norfeatures.video, nor theliveStreamcontent type. See The ELTV livestream.
The video section is the video bolt-on domain, one of the three this site is
composed of. It is built from three packages: @v4/ghost-video (the components),
@v4/media (the platform SDK layer), and @v4/video-app (the routes) — all three
template-owned, so their changes arrive in /sync-upstream pull requests (see
Take template updates).
The MediaPlayer — provider-blind by consumers
@v4/media/player exports one client component, MediaPlayer. It consumes a
MediaPlaybackView — provider plus source URLs from the platform playback
endpoint — never provider-native ids, so a provider swap is a branch here and
nowhere else:
brightcove(and any unknown provider carrying HLS/DASH sources) uses the Brightcove web SDK, dynamic-imported inside an effect because it touchesdocumentat module scope. Source preference is HLS → DASH → mp4 → first.youtuberenders ayoutube-nocookieembed iframe, keeping every YouTube-backed space working through the same component.muxrenders Mux's hosted player (player.mux.com) in an iframe by the public playback id — no SDK. This is ELB's provider: its Video Items are Mux-only sinceclub/0053-video-item-mux-only.cjs.
The Brightcove account id arrives through configureMedia from club.config.ts.
ghost-video's components stay provider-blind: they take media slots that the
player fills, and the entitlement overlays stay presentational — the entitlement
decision belongs to the fetchers and the auth feature, not the component.
One
MediaPlayerwith three provider branches (Brightcove, YouTube, Mux) is what shipped. The earlier design sketch of an adapter registry with per-provider subpaths was not built; it remains an option if the site ever needs another provider with a real SDK. Server-side a video is just CMS data (title, poster, provider, provider id) — the variance that would justify a registry is almost entirely client-side.
The routes
@v4/video-app exports five per-route factories over ghost-video, listed in
its routes.json and wired into the app by compose-club (see
Bolt-on route packages). Every route takes
client, media and locale, and every address carries the locale segment
(/en/video/…):
| Route | Segment | Factory |
|---|---|---|
| Landing | video |
createVideoLandingRoute |
| Collection | video/collections/[collection] |
createVideoCollectionRoute |
| Playlist | video/playlists/[playlist] |
createVideoPlaylistRoute |
| Category | video/category/[category], video/category/[category]/[...address] |
createVideoCategoryRoute |
| Detail | video/[idOrSlug] |
createVideoDetailRoute |
The category page lists a category's videos, filtered by season, team and
competition (filter pairs and page/<n> in the catch-all), with the filter
options read from the platform's /ContentData/*.
The package owns its own Contentful query and codegen, exactly like
@v4/content-app.
The landing's editor-first resolution
The landing's target is editor-controlled. It resolves the landing slug in three steps: the
editor-set siteSettings.defaultVideoLandingPage first, the club-config
landingSlug second, and "home" last. A Contentful outage on that lookup does
not take /video down — the factory falls back to a latest-videos rail from the
platform API, so a landing that is not configured in Contentful (or a Contentful
that is momentarily unreachable) still leaves a working page.
Competition video homes
The detail route serves a competition's video home at /en/video/<competition-slug>:
that competition's own Video Landing Page (the one whose slug is the
competition's), drawn as /video is; else a temporary page. The default
competition's redirects to /video, and a section without video (Corporate,
sections.capabilities in club.config.ts) is not found. See
Competition sections.
Previews and competition markers
The video hero autoplays, and a video card plays on hover, through
VideoPreview (@v4/media/preview): a muted inline HLS stream of the Mux
playback, which fetches nothing until the composer asks for it. Each listed
video carries a CompetitionMarker; the platform's responses carry no
competition, so packages/video-app/src/lib/video-competitions.ts reads the
Video Items' Competition in one query per list.
Images
Images are two different problems wearing one name, and the platform keeps them distinct: CMS assets from Contentful, and provider media (video posters, broadcaster and sponsor logos) that arrive pre-sized from provider CDNs. Both carry a focal point end to end, and neither ever has its URL constructed inside a component.
The allowlist is derived, never hand-maintained
A hand-edited next.config image allowlist rots as integrations come and go,
naming hosts nothing references and missing new ones. deriveImageHosts(config) computes images.remotePatterns from the resolved
config instead:
- Contentful's asset hosts (
images.ctfassets.net,downloads.ctfassets.net) whenevercontentis configured. - A video provider's thumbnail host when
features.videois on (i.ytimg.comfor YouTube, and the StreamAMG / Endeavor hosts for those providers). - The broadcaster logo host when
features.broadcastersis on. - The Competition Engine's crest and headshot CDNs (
media-cdn.incrowdsports.com,media-cdn.cortextech.io) whencompetitionEngineis configured. - Each enabled image provider's CDN:
dynamic-crop-cdn.scoreplay.iofor Scoreplay (on for this site),res.cloudinary.comfor Cloudinary, andimages.contentful.proxyHostwhen one is set.
Enabling a provider admits its host; disabling a feature removes it — the allowlist can no longer drift from the integration list.
Pluggable providers
Which CDN serves a picture is configuration. @v4/image is the pure core
(ImageRef, ImageRequest, ImageView, createResolver), and each provider is
its own adapter package. apps/web/src/lib/images.ts builds the resolver from
club.config.images and hands it to the render sites with configureImages;
the root layout imports it for that side effect. Euro League's config sets
defaultProvider: "scoreplay": an Image entry carrying a Scoreplay descriptor
is cropped by Scoreplay's dynamic-crop CDN (@v4/image-scoreplay), and every
native Contentful asset falls back to the Images API (@v4/image-contentful).
@v4/image-cloudinary exists but is not an app dependency. The guide is
Images and focal points.
Focal points end to end
A focal point survives from the CMS or the platform API all the way to a CSS
object-position, so an off-centre subject is never cropped through the head.
The maths lives once, in the pure @v4/image core (objectPositionFromNormalised,
nearestNamedRegion), which both the model and @v4/media import; each side
only normalises its own input. Both are junk-tolerant by construction, returning
undefined (so consumers fall back to the browser's 50% 50%) for any missing
or malformed shape:
- CMS assets. The Contentful Focal Point app stores
{ focalPoint: { x, y } }in pixels of the original asset.objectPositionFromFocalPoint(packages/model/src/core/transforms/image-source.ts, re-exported bycontent/transforms/focal-point.ts) converts it to clampedobject-positionpercentages against the asset's dimensions. - Provider media. The platform API returns
focalPointas a comma-separated"x,y"string in one of two encodings — normalised 0–1, or legacy pixel coordinates that need the image dimensions. The media view's normaliser handles both and emitsobjectPositionon everyMediaImageView.
Transforms emit data, components fill slots
The house boundary holds for images as for everything else: transforms emit
data, never markup. A transform produces { url, alt, width, height, objectPosition } plus a raw imageRef, which the render site resolves through
the configured provider; ghost components keep their ReactNode media slots and
the app fills them. No component ever constructs an image URL.
Two consequences follow from that split:
- Crop width is a request, not a fixed asset. For the Scoreplay dynamic-crop
CDN,
mediaImageUrl(image, width)re-requests the image at a surface-appropriate width (480 for cards, 1280 for heroes) — a no-op for hosts that do not take a width param we own. - The
sizesattribute is layout knowledge. It belongs where the layout lives — the route or page that placed the image in a grid — not in the transform and not in the component.
The ELTV livestream
EuroLeague TV live streams are designed to play inside the game page, through Deltatre's
Vesper Web SDK; everything else (VOD, accounts, payments) links out to the ELTV site. The frontend
(the livestream player) is built; the backend it needs (the livestream access endpoint) is not built. The game
page now exists — the Game Centre, at /en/games/<game>
(packages/basketball-app/src/games/game-centre.tsx) — but it does not mount the player: it
links out to ELTV instead ("Watch on ELTV" / "Watch live on ELTV" / "Replay on ELTV", to
NEXT_PUBLIC_ELTV_WATCH_URL, default https://euroleague.tv). Nothing outside packages/eltv
renders LivestreamPlayer today. This page is the contract between the two halves, and the reason
the frontend changes by one environment variable once the backend lands and the Game Centre
composes the player.
Three pieces, one seam
| Piece | Package | Owns |
|---|---|---|
| The vendor seam | @v4/eltv (this site's own) |
The ONLY importer of @dicetechnology/vesper-sdk-web (pinned exactly, 2.0.0). The access contract, the state machine |
| The frame | @v4/ghost-video LivestreamFrame |
The vendor-neutral 16:9 well and every state around the player. Code-only until Design delivers |
| The player (glue) | @v4/eltv/player |
LivestreamPlayer — composes the access answer, the Vesper mount and the frame. The Game Centre will render it |
@v4/eltv is named for the PRODUCT, not the vendor: when the OTT RFP replaces Deltatre, only
src/vesper/ and src/client/vesper/ change — the page's import, the frame and this contract do
not. It is unrelated to @v4/media (the Stadion platform seam), to features.video and to the
video domain's liveStream content type, and it reads no Contentful data at all.
What the SDK enforces, and what only the backend can say
Read from the 2.0.0 bundle, not assumed:
- The SDK cannot play anything without a token.
createPlayerManager()reads the app-suppliedAuthManager, JWT-decodes the auth token (sub) and throws without one — there is no guest fallback. - Frontoffice decides geo and entitlement on the SDK's own load call too.
load()asks/api/v4/event/{id}with the reader's token, from the reader's IP, withdisplayGeoblockedLive=false— and then again every five seconds through the heartbeat, where HTTP 401 and 403 both arrive as the geo beacon and 409 as concurrency. PlayerManager.load()hides every failure: it emits a code-lessSOURCE_ERRORand resolves. A stream that has not started, has ended, or that the reader may not watch all look the same at load — so a geo-blocked or unentitled reader whose backend answer isliveends on "Something went wrong / Try again", not on the defined state the ticket asks for. Only the backend can say any of it BEFORE the SDK is loaded.- The first heartbeat is sent when the manifest's metadata loads, not when playback starts.
load()with autoplay off therefore already registers with Vesper's session count; whether that takes a concurrency slot (and stops "the older session" — a reader opening the game page while watching on a TV) is Deltatre's to confirm. If it does, the player mounts on a play gesture instead. - Vesper event ids map to no other platform, and Vesper's frontoffice API "must never be proxied" through our own backend — the reader's browser calls it.
So the backend is the only source of which stream a game has, whether it has started or ended, and the reader's standing before playback. The player merges the two: any non-live backend answer wins over any player signal, and a refusal or failure during playback earns ONE backend re-check before any copy is chosen (an ended stream never shows as an error).
The access contract
One endpoint, asked from the reader's browser: GET <accessUrl with {gameCode} filled in>, where
{gameCode} is the Competition Engine game code. The body is a versioned union
(packages/eltv/src/access.ts, normalised by readLivestreamAccess; a body it refuses is a
failure, never a guess):
status |
Fields | The reader sees |
|---|---|---|
noStream |
pollAfterSeconds? |
Nothing — the player does not render |
notStarted |
startsAt (ISO 8601), pollAfterSeconds? |
The "Stream starts" clock; polled until it goes live |
live |
eventId, tokens: { authToken, refreshToken } |
The player |
watchOnEltv |
watchUrl? |
"Watch on EuroLeague TV" — live, but not embeddable for this reader |
ended |
watchUrl? |
"This stream has ended" + a link-out |
loginRequired |
— | "Log in to watch" + "Log in" (Cortex — sign-in now exists on the site as @v4/cortex-sso; the button stays absent until NEXT_PUBLIC_ELTV_LOGIN_URL is configured) + a link-out |
notEntitled |
watchUrl? |
"Not available on your account" + a link-out |
geoBlocked |
— | "Not available in your region", no action |
Every body carries "version": 1. Examples (the fixtures in packages/eltv/src/fixtures.ts are the
full set):
{ "version": 1, "status": "notStarted", "startsAt": "2026-11-27T19:45:00Z", "pollAfterSeconds": 60 }
{ "version": 1, "status": "live", "eventId": "123456",
"tokens": { "authToken": "<Vesper JWT>", "refreshToken": "<refresh token>" } }
{ "version": 1, "status": "ended", "watchUrl": "https://euroleague.tv/video/123456" }
HTTP rules for the backend:
- Answer 200 for every expected outcome above — a logged-out reader is a 200
loginRequired, never a 401. Any non-2xx (401 and 403 included) is a failure the player shows as "Something went wrong" with a retry;Retry-Afteris not read. - Answer within 10 seconds. The player aborts the request after that and shows the same error.
Cache-Control: private, no-store— the answer is per reader and per minute.- Credentialed CORS for the site's exact origins:
Access-Control-Allow-Origin: <that origin>,Access-Control-Allow-Credentials: true,Vary: Origin(the request is a simple GET withcredentials: "include"and only anAcceptheader, so there is no preflight). The reader is identified by a cookie the endpoint's host can read; cross-site from the page, that cookie must beSameSite=None; Secure, and Safari's tracking prevention applies. - A
watchUrlis honoured only when it is https on the configured ELTV origin. - Token refresh is the same GET. When the SDK gets a 401 it asks for a new token; the player
asks this endpoint again, and a
liveanswer's tokens are the refreshed pair. That answer must carry a NEWLY mintedauthToken: the same token again is treated as a refusal and ends playback as "session expired" (a cached pair would do exactly that). Any other answer is shown as itself (anendedanswer shows as ended, not as a login prompt). The refresh token is stored by the SDK but never used by 2.0.0 (renewal goes through this endpoint), so an opaque, non-empty placeholder is acceptable in its place. - Polling:
notStartedis polled — every 5 minutes more than half an hour out, every minute in the last half hour, every 15 seconds in the last five minutes and after the advertised start, each ±20% jitter, clamped to 15 s – 5 min, and given up three hours afterstartsAt;noStreamonly when the answer carriespollAfterSeconds; nothing else, and nothing while live.pollAfterSecondsoverrides the band. Expect every reader on the page to ask every 15 seconds in the five minutes around tip-off.
The sign-in seam
The EuroLeague ID sign-in ticket rules that Stadion builds no authentication. The player's AuthManager
(packages/eltv/src/vesper/auth-relay.ts) is therefore a pass-through: tokens arrive only from
the backend's live answer and leave only to the SDK; they are held in memory for one player;
renewal asks the backend, never Vesper; there is no OIDC, login flow, cookie or storage of ours.
Disclosed for the PM's sign-off: the SDK itself writes both tokens to localStorage
(vesper-sdk-<realm>-<env>_authToken and _refresh, beside _apiKey, _realm and
_adsConfigurationStatus — observed on the staging realm on 2 October 2026, and still there after the
player is destroyed: the SDK never clears them). Should the PM rule tokens out of the browser altogether, the
backend answers watchOnEltv instead of live — and the frontend does not change.
Configuration
features.livestream in club.config.ts, declared only when NEXT_PUBLIC_VESPER_API_KEY is set
(the WSC presence pattern), resolved by the server-only apps/web/src/lib/eltv.ts and handed to the
player as props. Every value is public by nature; the variables are in
Environment variables. Without NEXT_PUBLIC_ELTV_ACCESS_URL the
player renders nothing — that one variable is what switches it on once the access endpoint exists.
LivestreamFrame is a @v4/ghost-video component, so the route that renders it must load the video
theme part. The app emits @v4/ghost-video's themes from apps/web/src/theme/tokens-video.css.ts,
which the /video layout and every data-area layout — app/[locale]/games/layout.tsx included —
import, so a player mounted in the Game Centre already has its variables. A route that did not load
the part would render the frame unstyled with nothing failing; that is how the staging run below
first found the gap.
What has been proven against staging
On 2 October 2026 a temporary local page (since removed) drove the real LivestreamPlayer against
Deltatre's staging realm (headless Chromium, the staging key, no account): the SDK chunk and its
stylesheet load only when the player connects; GET /api/v1/init answers 200 with CORS for
http://localhost:3000; GET /api/v4/event/{id} refuses an unsigned token with 401; the SDK asks the
relay for a fresh token, the relay refuses to re-issue the same one, and the frame lands on "session
expired" with the ELTV link. Every fixture state rendered at 16:9 at 1280 and 360.
Nothing in the app mounts the player until the Game Centre composes it, so real playback is proven
there next. For a local run then: the staging realm's domains list localhost:3001 (its CORS also echoed
http://localhost:3000), so use -p 3001, with NEXT_PUBLIC_SITE_BASE_URL to match, for anything
domain-gated such as a login redirectTo; and the app resolves @v4/ghost-video and @v4/model
from their builds, so run pnpm build once (or pnpm --filter @v4/ghost-video --filter @v4/model build) before pnpm --filter @v4/web dev.
Not yet decided
Every open question, by the owner who answers it. The frontend is built to take either answer wherever one is marked so.
Deltatre / Vesper
- Register our Flightcontrol dev and staging hosts and the production host in BOTH realms'
domains, CORS and loginredirectTo. Neither realm lists any of them today. - How does a reader who logged in with EuroLeague ID get a Vesper token pair? The public docs
describe no server-side login or token exchange, and say the frontoffice API is called from the
reader's device only. The restricted API Reference (per-user access) is where the answer lives.
Without one, the backend answers
watchOnEltvand the embed is a link-out for logged-in readers (no frontend change). - Is the 2.0.0 beacon mapping intended (heartbeat 401/403 → the geo event,
BEACON_ACCESS_DENIEDunreachable)? What doesload()surface for not-entitled, geo, not-started and ended? - Does a live stream's end produce
PlayerState.IDLE? (The player re-asks the backend on it.) - The concurrency limit — and does the heartbeat sent at metadata load, before any play, take a
slot? Does
destroy()stop the heartbeat? - DRM and the browser matrix (iOS FairPlay). How are ads and analytics switched off pending consent
(the consent ticket)? The production realm has ads ENABLED; Mux analytics runs with a compiled-in fallback
key even when the realm sets none;
initConfig.settingsis the integrator's only lever. - Is the SDK's
localStoragetoken persistence configurable? (Not in 2.0.0, as far as the bundle shows.) - A 24/7 staging test event, an entitled staging account (or a pre-issued token pair), IP overrides
for geo testing, and a sample app that targets 2.0.0 (the public one targets 1.x and has no
baseUrl).
Backend (the access endpoint)
- Accept this contract:
version: 1, the eight statuses, 200 for every expected outcome, refresh = the same GET with a freshly minted token. - The host and URL template; credentialed CORS for the site's exact origins.
- How the reader is identified: a same-site cookie means a EuroLeague subdomain; cross-site means
SameSite=Noneand Safari's tracking prevention. - Tokens at all (
live), orwatchOnEltv? - The game → event mapping key ("no manual mapping per match" is the ticket's criterion; Vesper's
ids map to nothing external),
notStarted.startsAtandendedfrom the event's schedule, and the pre-playback geo and entitlement sources — Vesper names none a server may call. pollAfterSecondsand rate limits around tip-off; a dev endpoint or the fixtures as the agreed bodies.
Design (the livestream design ticket)
- All 13 states, concurrency and session-expired included — the
AllNoticesstory is the inventory. - A primary LINK call-to-action style (
ButtonCommonis a button only;ButtonTextrenders the link today). - Same tab or new tab for the link-outs, and their copy.
- The 16:9 well and its growth at 360 px.
- A LIVE pill outside the well, or none.
- The clock: the reader's time zone (today) or CET.
- Autoplay off (today), muted, or click-to-play (which also answers Deltatre 5).
- Does
noStreamhide the section? - Which of the SDK's default controls stay (quality, picture-in-picture, Chromecast, poster).
PM
- Sign-in: accept the pass-through relay plus the SDK's own
localStoragepersistence, or backend-sidewatchOnEltvonly? - Accept that merging
keys.tsauto-seeds the 19liveStreamPlayer.*keys into Contentfuldevelop; sign off their placeholder copy. The route and name ("Match Centre" vs "Game Center")— answered: the Game Centre, at/en/games/<game>, built in@v4/basketball-app. Still open: whether and when it composes the player rather than linking out.- Replace
NPM_TOKENin GitHub and Flightcontrol with a read-only token covering BOTH@stadionhq-v3and@dicetechnologybefore merging: the old CI token reads only the first, and the PR's first CI run failed its install with a 404 on the Vesper package. - Keep the key unset on production until consent is in.
- Scope: the RAID decision log puts streaming outside the MVP while the livestream epic is in progress, and the 30 September UX direction (free preview, in-player ads, in-page purchase) exceeds both the ticket and these states.