@v4/eltv

The EuroLeague TV livestream player over Deltatre's Vesper SDK: access contract, state machine, polling and token relay. Not yet on a page.

2026-11-07

Overview

@v4/eltv is the EuroLeague TV livestream player. It is the only package that imports Deltatre's Vesper Web SDK, @dicetechnology/vesper-sdk-web, pinned exactly at 2.0.0. It depends on @v4/ghost-video for the vendor-neutral LivestreamFrame. Club-only.

It is named for the PRODUCT, not the vendor: when the OTT RFP replaces Deltatre, the access contract and the page's import survive, and only src/vesper/ and src/client/vesper/ change.

Status (6 Nov 2026). The frontend is built and tested. The backend it talks to (the access endpoint) is not built, and the game page that will host the player does not exist yet, so nothing on the site renders the player today: livestreamSettings and livestreamLabels in apps/web/src/lib/eltv.ts have no consumer.

The access contract, the states and the architecture are documented in EuroLeague TV livestream, and its place among the club's other third-party seams in Club integrations. This page is the package surface.

Entry points

Import What it holds Imports the SDK / design system
@v4/eltv Pure and server-safe: the access contract, the state machine, polling, links, formatting, the token relay The SDK by TYPE only; no design system
@v4/eltv/client "use client": the Vesper mount, the access hook, the HTTP access source The SDK by value, through one import() in an effect; no design system
@v4/eltv/player "use client": the composed LivestreamPlayer Both

The root may import the SDK only as types because the SDK's bundle reads self and document the moment it is evaluated.

@v4/eltv (src/index.ts)

  • Access contract (src/access.ts) — readLivestreamAccess, LIVESTREAM_ACCESS_VERSION (1; any other version is refused), LIVESTREAM_ACCESS_STATUSES: noStream, notStarted, live, watchOnEltv, ended, loginRequired, notEntitled, geoBlocked. Types LivestreamAccess, LivestreamAccessStatus, PlaybackTokens, AccessSource (a source never rejects), AccessResult, AccessFailure, AccessRequest, AccessRequestReason, ReadAccessOptions. The backend's body is normalised once at the boundary, and a body that cannot be trusted is a failure, never a guess.

  • Fixtures (src/fixtures.ts) — createFixtureSource, FIXTURE_WATCH_ORIGIN, LIVESTREAM_ACCESS_FIXTURES (one raw body per status), types FixtureAnswer, FixtureRequest. Stories and tests run the player with no backend.

  • State machine (src/state.ts) — resolveLivestreamState, isTerminalSignal, recheckFor; types LivestreamState, LivestreamStateKind, LivestreamInputs, PlayerSignal, PlayerSignalKind, RecheckPhase. The precedence is fixed:

    1. no answer yet → checking;
    2. a transport failure with no good answer → error;
    3. any non-live backend status beats any player signal (so a stream that ended never shows as an error);
    4. only while the backend says live do the player's signals decide — and a refused or failed signal earns ONE re-check with the backend before the copy is chosen, because the SDK's load() reports every failure as the same code-less SOURCE_ERROR, and a heartbeat 401 and 403 both arrive as the geo beacon.
  • Links (src/links.ts) — accessRequestUrl with ACCESS_GAME_PLACEHOLDER ({gameCode}), loginHref with LOGIN_RETURN_PLACEHOLDER ({returnTo}), watchHref, watchOriginOf, sanitiseWatchUrl (a watch URL from the backend survives only on the ELTV origin). The return URL is built on the server from seo.baseUrl, never from a query string.

  • Polling (src/polling.ts, src/settings.ts) — nextPollDelayMs, shouldResume, and the bounds:

    Constant Value Meaning
    LIVESTREAM_ACCESS_TIMEOUT_MS 10 s One access request, before it counts as a failure
    LIVESTREAM_START_TIMEOUT_MS 20 s SDK import to a loaded stream (nothing in the SDK bounds it)
    LIVESTREAM_POLL_MIN_MS / _MAX_MS 15 s / 5 min The access poll while a stream has not started
    LIVESTREAM_RESUME_STALE_MS 30 s A returning tab re-asks only when the last answer is older
    LIVESTREAM_POLL_GIVE_UP_MS 3 h Stop polling a stream still not started this long after its start
  • Settings and copy — types LivestreamSettings, VesperSettings, VesperEnvironment (prod | stag), LivestreamAutoplay (off, the default, or muted — design to decide), LivestreamLabels, LivestreamNoticeCopy; formatStartsAt with FormatOptions and StartsAtParts for the "Stream starts" clock.

  • The token relay (src/vesper/auth-relay.ts) — createRelayAuthManager and RenewTokens. This is THE sign-in seam and the only place a Vesper token is handled. It takes tokens only from the backend's access answer, gives them only to the SDK, holds them in memory for one player mount, and renews by asking the backend again — no OIDC, no login flow, no cookies, no storage, no request of its own.

    Disclosed for sign-off: the SDK itself writes both tokens to localStorage (vesper-sdk-<realm>-<env>_authToken and _refresh) and never clears them, not even on destroy(). That is the vendor's behaviour. If tokens are ruled out of the browser altogether, the backend answers watchOnEltv instead of live and the relay is never reached.

@v4/eltv/client (src/client/index.ts)

  • LivestreamMount and LivestreamMountProps — the Vesper mount. The SDK is imported by value only through the one import() in src/client/vesper/load.ts, inside an effect.
  • useLivestreamAccess and LivestreamAccessHandle — asks, polls and re-checks.
  • httpAccessSource and HttpAccessSourceOptions — the browser GET to the access endpoint: with credentials, only an Accept header (so a CORS simple request), no-store.

@v4/eltv/player (src/player/index.ts)

  • LivestreamPlayer and LivestreamPlayerProps — gameCode (the Competition Engine's, opaque), settings (undefined renders nothing), labels (resolved on the server), locale, returnUrl (built on the server), optional timeZone, autoplay (default off; the SDK itself defaults to autoplay) and source (fixtures in tests). Keyed by gameCode.
  • frameViewFor and FrameViewContext — maps a state onto ghost-video's LivestreamFrame view.

src/vesper-sdk-css.d.ts declares the SDK's stylesheet as a module rather than using @ts-expect-error, because apps/web also compiles this source through transpilePackages.

Configuration

The feature is declared in apps/web/club.config.ts (features.livestream) only when the key is set. Every value is public by nature: the SDK sends them from the reader's browser (x-api-key, Realm). Per-environment values are in 1Password. Leave a variable unset, never empty.

Variable Required Meaning
NEXT_PUBLIC_VESPER_API_KEY The switch Unset: OFF — no player, and the ~3.2 MB SDK is never downloaded
NEXT_PUBLIC_VESPER_BASE_URL With the key Vesper's API origin, no path or trailing slash (staging https://euroleague.api.onvesper-staging.com, production https://euroleague.api.onvesper.com)
NEXT_PUBLIC_VESPER_ENVIRONMENT With the key stag or prod. No default: a staging key must never run as production
NEXT_PUBLIC_ELTV_WATCH_URL With the key The ELTV site every link-out falls back to (https://euroleague.tv)
NEXT_PUBLIC_ELTV_LOGIN_URL Optional The login page; may contain {returnTo}. Absent: no login call to action
NEXT_PUBLIC_ELTV_ACCESS_URL Optional The livestream access endpoint, a template containing {gameCode}. Absent: the player renders nothing

The watch, login and access URLs must all be https. The realm (dce.euroleague) is a club fact written in club.config.ts, not an environment variable. Vesper's server-held credentials belong to the access backend, never here. The schema is features.livestream in packages/template-config/src/schema.ts.

The SDK is a private npm package: NPM_TOKEN (locally in the root .env, the GitHub secret in CI) must be able to read @dicetechnology as well as @stadionhq-v3.

How the app wires it

File Role
apps/web/src/lib/eltv.ts The ONE config point, server-only: livestreamSettings (undefined when off), livestreamLabels(t) (the liveStreamPlayer.* dictionary keys, plus the reused subscriptionOverlay.membersOnly.logInCTA, error.title and error.retry), and the _EnvironmentsAgree compile-time check that the schema's environments and VesperEnvironment agree both ways
apps/web/src/lib/analytics.ts The watch host joins the eltv outbound destinations
apps/web/next.config.ts Listed in transpilePackages

There is no provider in the root layout: one player sits on one page, so the game page will pass livestreamSettings and livestreamLabels(t) to LivestreamPlayer as props.

Scripts and tests

test / test:watch (vitest, jsdom), lint, check-types. Suites cover the access contract, the state machine, polling, formatting, links, the token relay and signal map, the access source, the mount, the hook and the SDK loader, the player and its frame view, and a server render of each "use client" entry. The SDK's test double is src/__fixtures__/vesper.ts.