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.

2026-11-07

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:

  • getPlayback is cache: "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/eltv over Deltatre's Vesper SDK — and use neither MediaPlayer, nor features.video, nor the liveStream content 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 touches document at module scope. Source preference is HLS → DASH → mp4 → first.
  • youtube renders a youtube-nocookie embed iframe, keeping every YouTube-backed space working through the same component.
  • mux renders 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 since club/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 MediaPlayer with 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) whenever content is configured.
  • A video provider's thumbnail host when features.video is on (i.ytimg.com for YouTube, and the StreamAMG / Endeavor hosts for those providers).
  • The broadcaster logo host when features.broadcasters is on.
  • The Competition Engine's crest and headshot CDNs (media-cdn.incrowdsports.com, media-cdn.cortextech.io) when competitionEngine is configured.
  • Each enabled image provider's CDN: dynamic-crop-cdn.scoreplay.io for Scoreplay (on for this site), res.cloudinary.com for Cloudinary, and images.contentful.proxyHost when 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 by content/transforms/focal-point.ts) converts it to clamped object-position percentages against the asset's dimensions.
  • Provider media. The platform API returns focalPoint as 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 emits objectPosition on every MediaImageView.

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 sizes attribute 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-supplied AuthManager, 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, with displayGeoblockedLive=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-less SOURCE_ERROR and 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 is live ends 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-After is 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 with credentials: "include" and only an Accept header, 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 be SameSite=None; Secure, and Safari's tracking prevention applies.
  • A watchUrl is 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 live answer's tokens are the refreshed pair. That answer must carry a NEWLY minted authToken: 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 (an ended answer 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: notStarted is 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 after startsAt; noStream only when the answer carries pollAfterSeconds; nothing else, and nothing while live. pollAfterSeconds overrides 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

  1. Register our Flightcontrol dev and staging hosts and the production host in BOTH realms' domains, CORS and login redirectTo. Neither realm lists any of them today.
  2. 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 watchOnEltv and the embed is a link-out for logged-in readers (no frontend change).
  3. Is the 2.0.0 beacon mapping intended (heartbeat 401/403 → the geo event, BEACON_ACCESS_DENIED unreachable)? What does load() surface for not-entitled, geo, not-started and ended?
  4. Does a live stream's end produce PlayerState.IDLE? (The player re-asks the backend on it.)
  5. The concurrency limit — and does the heartbeat sent at metadata load, before any play, take a slot? Does destroy() stop the heartbeat?
  6. 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.settings is the integrator's only lever.
  7. Is the SDK's localStorage token persistence configurable? (Not in 2.0.0, as far as the bundle shows.)
  8. 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)

  1. Accept this contract: version: 1, the eight statuses, 200 for every expected outcome, refresh = the same GET with a freshly minted token.
  2. The host and URL template; credentialed CORS for the site's exact origins.
  3. How the reader is identified: a same-site cookie means a EuroLeague subdomain; cross-site means SameSite=None and Safari's tracking prevention.
  4. Tokens at all (live), or watchOnEltv?
  5. The game → event mapping key ("no manual mapping per match" is the ticket's criterion; Vesper's ids map to nothing external), notStarted.startsAt and ended from the event's schedule, and the pre-playback geo and entitlement sources — Vesper names none a server may call.
  6. pollAfterSeconds and rate limits around tip-off; a dev endpoint or the fixtures as the agreed bodies.

Design (the livestream design ticket)

  1. All 13 states, concurrency and session-expired included — the AllNotices story is the inventory.
  2. A primary LINK call-to-action style (ButtonCommon is a button only; ButtonText renders the link today).
  3. Same tab or new tab for the link-outs, and their copy.
  4. The 16:9 well and its growth at 360 px.
  5. A LIVE pill outside the well, or none.
  6. The clock: the reader's time zone (today) or CET.
  7. Autoplay off (today), muted, or click-to-play (which also answers Deltatre 5).
  8. Does noStream hide the section?
  9. Which of the SDK's default controls stay (quality, picture-in-picture, Chromecast, poster).

PM

  1. Sign-in: accept the pass-through relay plus the SDK's own localStorage persistence, or backend-side watchOnEltv only?
  2. Accept that merging keys.ts auto-seeds the 19 liveStreamPlayer.* keys into Contentful develop; sign off their placeholder copy.
  3. 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.
  4. Replace NPM_TOKEN in GitHub and Flightcontrol with a read-only token covering BOTH @stadionhq-v3 and @dicetechnology before 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.
  5. Keep the key unset on production until consent is in.
  6. 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.